chore: auto-commit (21 archivos)

- app.md
- backend/dist/assets/index-D_Kep7Fb.js
- backend/dist/index.html
- backend/handlers.go
- backend/main.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/src/components/CardChatPanel.tsx
- frontend/src/components/LoginPage.tsx
- frontend/src/types.ts
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 18:17:04 +02:00
parent 1923fd31a4
commit 2524340759
20 changed files with 2165 additions and 236 deletions
+96 -7
View File
@@ -81,7 +81,9 @@ import { StickerPicker } from "./components/StickerPicker";
import { ColorPickerGrid, CustomColorModal } from "./components/ColorPickerGrid";
import { AVATAR_COLORS } from "./components/colors";
import { colorBg, colorBorder } from "./components/colors";
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
import { NotificationsBell } from "./components/NotificationsBell";
import { useEventStream } from "./hooks/useEventStream";
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
const COL_PREFIX = "column-";
@@ -326,12 +328,71 @@ export function App() {
return () => clearInterval(t);
}, [activeCard, activeColumnId]);
// Notifications state (populated by SSE + initial fetch).
const [notifs, setNotifs] = useState<Notification[]>([]);
const [notifUnread, setNotifUnread] = useState(0);
// Build version (injected at compile time via -ldflags). Fetched once.
const [appVersion, setAppVersion] = useState<string>("");
useEffect(() => {
const t = setInterval(() => {
reload();
}, 30000);
return () => clearInterval(t);
}, [reload]);
api
.getVersion()
.then((v) => setAppVersion(v.version))
.catch(() => setAppVersion(""));
}, []);
const reloadNotifs = useCallback(async () => {
try {
const [list, c] = await Promise.all([api.listNotifications(false), api.unreadNotificationCount()]);
setNotifs(list);
setNotifUnread(c.count);
} catch {
// best-effort; SSE will reconcile
}
}, []);
useEffect(() => {
if (auth.user) reloadNotifs();
}, [auth.user, reloadNotifs]);
// Replace 30s polling with SSE. Server pushes board.invalidated on every
// mutation, message.created on chat traffic and notification.created on
// per-user notifications. We refetch /api/board on invalidate (cheap +
// keeps merge logic simple) and patch notification state in-place.
useEventStream(
useMemo(
() => ({
"board.invalidated": () => {
reload();
},
"notification.created": (payload: unknown) => {
const n = payload as Notification;
if (!n || !n.id) return;
setNotifs((prev) => (prev.some((x) => x.id === n.id) ? prev : [n, ...prev].slice(0, 100)));
setNotifUnread((c) => c + 1);
const who = n.actor_name || "Alguien";
const card = n.card_seq_num ? `#${n.card_seq_num}` : n.card_title;
notifications.show({
color: n.kind === "mention" ? "grape" : "blue",
title: `${who} en ${card}`,
message: n.snippet,
});
},
"notification.read": (payload: unknown) => {
const p = payload as { id?: string } | null;
if (!p?.id) return;
setNotifs((prev) => prev.map((x) => (x.id === p.id ? { ...x, read_at: new Date().toISOString() } : x)));
setNotifUnread((c) => Math.max(0, c - 1));
},
"notification.read_all": () => {
setNotifs((prev) => prev.map((x) => (x.read_at ? x : { ...x, read_at: new Date().toISOString() })));
setNotifUnread(0);
},
}),
[reload],
),
!!auth.user,
);
useEffect(() => {
if (!activeSticker) return;
@@ -1113,6 +1174,25 @@ export function App() {
<ActionIcon variant="subtle" onClick={reload} aria-label="Refresh">
<IconRefresh size={16} />
</ActionIcon>
{auth.user && (
<NotificationsBell
unreadCount={notifUnread}
notifications={notifs}
onOpenCard={async (cardId) => {
const card = board?.cards.find((c) => c.id === cardId);
if (card) {
setActiveCard(card);
} else {
// Card may be archived/trashed/missing locally — refetch and retry.
await reload();
const b = await api.getBoard();
const c2 = b.cards.find((c) => c.id === cardId);
if (c2) setActiveCard(c2);
}
}}
onChanged={reloadNotifs}
/>
)}
<ActionIcon
variant={chatOpen ? "filled" : "subtle"}
onClick={() => setChatOpen((v) => !v)}
@@ -1130,7 +1210,16 @@ export function App() {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{auth.user.display_name || auth.user.username}</Menu.Label>
<Menu.Label>
<Group justify="space-between" gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{auth.user.display_name || auth.user.username}
</Text>
{appVersion && (
<Text size="xs" c="dimmed" ff="monospace">v{appVersion}</Text>
)}
</Group>
</Menu.Label>
<Box p="xs">
<Text size="xs" c="dimmed" mb={4}>Color del avatar</Text>
<ColorPickerGrid