chore: auto-commit (23 archivos)
- app.md - backend/auth.go - backend/db.go - backend/dist/assets/index-CPqSy0gZ.js - backend/dist/index.html - backend/handlers.go - backend/main.go - frontend/src/App.tsx - frontend/src/api.ts - frontend/src/components/KanbanCard.tsx - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconSend, IconTrash } from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { CardMessage, User } from "../types";
|
||||
import { tagColor } from "./colors";
|
||||
import { formatDateTimeShort } from "./format";
|
||||
|
||||
interface Props {
|
||||
cardId: string;
|
||||
users: User[];
|
||||
currentUserId?: string;
|
||||
onMessagesChange?: (messages: CardMessage[]) => void;
|
||||
}
|
||||
|
||||
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }: Props) {
|
||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [body, setBody] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const usersById = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const ms = await api.listCardMessages(cardId);
|
||||
setMessages(ms);
|
||||
onMessagesChange?.(ms);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cardId, onMessagesChange]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewportRef.current) {
|
||||
viewportRef.current.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
const send = async () => {
|
||||
const text = body.trim();
|
||||
if (!text || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const m = await api.createCardMessage(cardId, text);
|
||||
const next = [...messages, m];
|
||||
setMessages(next);
|
||||
onMessagesChange?.(next);
|
||||
setBody("");
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (mid: string) => {
|
||||
try {
|
||||
await api.deleteCardMessage(cardId, mid);
|
||||
const next = messages.filter((m) => m.id !== mid);
|
||||
setMessages(next);
|
||||
onMessagesChange?.(next);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs" style={{ height: "100%", minHeight: 0 }}>
|
||||
<ScrollArea
|
||||
viewportRef={viewportRef}
|
||||
style={{ flex: 1, minHeight: 200 }}
|
||||
type="auto"
|
||||
offsetScrollbars
|
||||
>
|
||||
{loading ? (
|
||||
<Group justify="center" p="md"><Loader size="sm" /></Group>
|
||||
) : messages.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||
Sin mensajes aun. Escribe el primero.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6} p={4}>
|
||||
{messages.map((m) => {
|
||||
const author = m.author_id ? usersById.get(m.author_id) : null;
|
||||
const isMe = m.author_id && m.author_id === currentUserId;
|
||||
const label = author ? author.display_name || author.username : "Anonimo";
|
||||
return (
|
||||
<Paper
|
||||
key={m.id}
|
||||
withBorder
|
||||
p="xs"
|
||||
radius="sm"
|
||||
bg={isMe ? "var(--mantine-color-blue-light)" : undefined}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||
<Avatar size={22} radius="xl" color={author?.color || tagColor(label)}>
|
||||
{label.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600}>{label}</Text>
|
||||
<Text size="xs" c="dimmed">{formatDateTimeShort(m.created_at)}</Text>
|
||||
</Group>
|
||||
{isMe && (
|
||||
<Tooltip label="Borrar" withArrow>
|
||||
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => remove(m.id)}>
|
||||
<IconTrash size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{m.body}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Escribe un mensaje (Enter = enviar, Shift+Enter = salto)"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
style={{ flex: 1 }}
|
||||
disabled={sending}
|
||||
/>
|
||||
<Tooltip label="Enviar" withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={send}
|
||||
disabled={!body.trim() || sending}
|
||||
aria-label="Enviar"
|
||||
>
|
||||
<IconSend size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Box, Divider, Group, Tabs, Text } from "@mantine/core";
|
||||
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import type { Card, CardMessage, User } from "../types";
|
||||
import { CardChatPanel } from "./CardChatPanel";
|
||||
import { CardLinksPanel } from "./CardLinksPanel";
|
||||
import { CardForm, CardFormValues } from "./CardForm";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
users: User[];
|
||||
currentUserId?: string;
|
||||
requesterOptions: string[];
|
||||
tagOptions: string[];
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CardEditPanel({
|
||||
card,
|
||||
users,
|
||||
currentUserId,
|
||||
requesterOptions,
|
||||
tagOptions,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||
const [liveCard, setLiveCard] = useState(card);
|
||||
|
||||
const wrappedSubmit = async (v: CardFormValues) => {
|
||||
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
|
||||
await onSubmit(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
|
||||
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
|
||||
<CardForm
|
||||
users={users}
|
||||
requesterOptions={requesterOptions}
|
||||
tagOptions={tagOptions}
|
||||
initial={{
|
||||
requester: liveCard.requester,
|
||||
title: liveCard.title,
|
||||
description: liveCard.description,
|
||||
assignee_id: liveCard.assignee_id,
|
||||
tags: liveCard.tags || [],
|
||||
}}
|
||||
submitLabel="Guardar"
|
||||
onSubmit={wrappedSubmit}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
<Divider orientation="vertical" />
|
||||
<Box style={{ flex: "1 1 0", minWidth: 320, display: "flex", flexDirection: "column" }}>
|
||||
<Tabs defaultValue="chat" keepMounted={false} style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
|
||||
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
|
||||
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />} disabled>Archivos</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||
<Box style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<CardChatPanel
|
||||
cardId={liveCard.id}
|
||||
users={users}
|
||||
currentUserId={currentUserId}
|
||||
onMessagesChange={setMessages}
|
||||
/>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="links">
|
||||
<CardLinksPanel card={liveCard} messages={messages} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="files">
|
||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||
Proximamente: adjuntos de archivos.
|
||||
</Text>
|
||||
</Tabs.Panel>
|
||||
</Box>
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Anchor, Badge, Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useMemo } from "react";
|
||||
import type { Card, CardMessage } from "../types";
|
||||
|
||||
interface ExtractedLink {
|
||||
url: string;
|
||||
source: "title" | "description" | "chat";
|
||||
context: string;
|
||||
}
|
||||
|
||||
const URL_RE = /(https?:\/\/[^\s<>()"']+)/gi;
|
||||
|
||||
function extract(source: ExtractedLink["source"], text: string): ExtractedLink[] {
|
||||
if (!text) return [];
|
||||
const out: ExtractedLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
URL_RE.lastIndex = 0;
|
||||
while ((m = URL_RE.exec(text)) !== null) {
|
||||
let url = m[1];
|
||||
// Strip common trailing punctuation that isn't part of a URL.
|
||||
url = url.replace(/[.,;:!?)\]}>]+$/, "");
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
out.push({ url, source, context: text });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hostname(u: string): string {
|
||||
try {
|
||||
return new URL(u).hostname;
|
||||
} catch {
|
||||
return u;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
messages: CardMessage[];
|
||||
}
|
||||
|
||||
export function CardLinksPanel({ card, messages }: Props) {
|
||||
const links = useMemo<ExtractedLink[]>(() => {
|
||||
const all: ExtractedLink[] = [
|
||||
...extract("title", card.title),
|
||||
...extract("description", card.description),
|
||||
...messages.flatMap((m) => extract("chat", m.body)),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return all.filter((l) => {
|
||||
if (seen.has(l.url)) return false;
|
||||
seen.add(l.url);
|
||||
return true;
|
||||
});
|
||||
}, [card.title, card.description, messages]);
|
||||
|
||||
if (links.length === 0) {
|
||||
return (
|
||||
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 200 }}>
|
||||
<Text size="sm" c="dimmed">Sin enlaces detectados</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Pega URLs en el titulo, descripcion o chat y apareceran aqui.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeColor = (s: ExtractedLink["source"]): string => {
|
||||
if (s === "title") return "grape";
|
||||
if (s === "description") return "blue";
|
||||
return "teal";
|
||||
};
|
||||
|
||||
const badgeLabel = (s: ExtractedLink["source"]): string => {
|
||||
if (s === "title") return "titulo";
|
||||
if (s === "description") return "descripcion";
|
||||
return "chat";
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6} p={4}>
|
||||
{links.map((l) => (
|
||||
<Paper key={l.url} withBorder p="xs" radius="sm">
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between" align="flex-start">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Anchor href={l.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
|
||||
<Group gap={4} wrap="nowrap" align="center">
|
||||
<IconExternalLink size={12} />
|
||||
<span>{hostname(l.url)}</span>
|
||||
</Group>
|
||||
</Anchor>
|
||||
<Text size="xs" c="dimmed" style={{ wordBreak: "break-all" }}>{l.url}</Text>
|
||||
</Box>
|
||||
<Badge size="xs" variant="light" color={badgeColor(l.source)}>
|
||||
{badgeLabel(l.source)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IconCalendarDue,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconGripVertical,
|
||||
@@ -42,6 +43,7 @@ interface Props {
|
||||
now: number;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (card: Card) => void;
|
||||
onDuplicate?: (id: string) => void;
|
||||
onChangeColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleLock: (id: string, locked: boolean) => void;
|
||||
@@ -67,6 +69,7 @@ function KanbanCardImpl({
|
||||
now,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onChangeColor,
|
||||
onShowHistory,
|
||||
onToggleLock,
|
||||
@@ -206,6 +209,17 @@ function KanbanCardImpl({
|
||||
>
|
||||
Editar
|
||||
</Menu.Item>
|
||||
{onDuplicate && (
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={14} />}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onDuplicate(card.id);
|
||||
}}
|
||||
>
|
||||
Duplicar
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Popover
|
||||
opened={colorPopOpen}
|
||||
onChange={setColorPopOpen}
|
||||
|
||||
@@ -50,6 +50,7 @@ interface Props {
|
||||
onToggleDone: (id: string, is_done: boolean) => void;
|
||||
onEditCard: (card: Card) => void;
|
||||
onDeleteCard: (id: string) => void;
|
||||
onDuplicateCard: (id: string) => void;
|
||||
onChangeCardColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleCardLock: (id: string, locked: boolean) => void;
|
||||
@@ -82,6 +83,7 @@ function KanbanColumnImpl({
|
||||
onToggleDone,
|
||||
onEditCard,
|
||||
onDeleteCard,
|
||||
onDuplicateCard,
|
||||
onChangeCardColor,
|
||||
onShowHistory,
|
||||
onToggleCardLock,
|
||||
@@ -421,6 +423,7 @@ function KanbanColumnImpl({
|
||||
now={now}
|
||||
onDelete={onDeleteCard}
|
||||
onEdit={onEditCard}
|
||||
onDuplicate={onDuplicateCard}
|
||||
onChangeColor={onChangeCardColor}
|
||||
onShowHistory={onShowHistory}
|
||||
onToggleLock={onToggleCardLock}
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconLayoutKanban } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "../auth";
|
||||
import * as api from "../api";
|
||||
|
||||
type Mode = "login" | "register";
|
||||
|
||||
@@ -23,6 +24,18 @@ export function LoginPage() {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getFlags()
|
||||
.then((f) => setRegistrationEnabled(!!f["registration-enabled"]))
|
||||
.catch(() => setRegistrationEnabled(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registrationEnabled && mode === "register") setMode("login");
|
||||
}, [registrationEnabled, mode]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -84,20 +97,26 @@ export function LoginPage() {
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{mode === "login" ? "Entrar" : "Registrar"}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
}}
|
||||
>
|
||||
{mode === "login" ? "Registrate" : "Inicia sesion"}
|
||||
</Anchor>
|
||||
</Text>
|
||||
{registrationEnabled ? (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
}}
|
||||
>
|
||||
{mode === "login" ? "Registrate" : "Inicia sesion"}
|
||||
</Anchor>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Registro de nuevos usuarios deshabilitado.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
Reference in New Issue
Block a user