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:
2026-05-13 18:40:22 +02:00
parent f1ee116d3b
commit a34a8142cc
23 changed files with 2034 additions and 1184 deletions
+179
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+14
View File
@@ -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}
+3
View File
@@ -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}
+34 -15
View File
@@ -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>