feat(kanban): stickers feature + dashboard null guards (#0063)
- backend: Sticker type, idempotent stickers column, PUT /api/cards/:id/stickers, 4 tests - frontend: emoji-mart picker, toolbar button + ESC, draggable overlay with right-click delete, % coords for resize survival - dashboard: null guards on metrics arrays Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,7 @@ import {
|
||||
IconLogout,
|
||||
IconMenu2,
|
||||
IconMessageChatbot,
|
||||
IconMoodSmile,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
@@ -73,6 +74,7 @@ import { Dashboard } from "./components/Dashboard";
|
||||
import { HistoryModal } from "./components/HistoryModal";
|
||||
import { KanbanCard } from "./components/KanbanCard";
|
||||
import { KanbanColumn } from "./components/KanbanColumn";
|
||||
import { StickerPicker } from "./components/StickerPicker";
|
||||
import { colorBg, colorBorder } from "./components/colors";
|
||||
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
|
||||
|
||||
@@ -122,6 +124,8 @@ export function App() {
|
||||
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
||||
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
|
||||
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
|
||||
const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
|
||||
const [activeSticker, setActiveSticker] = useState<string | null>(null);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [navWidth, setNavWidth] = useState<number>(() => {
|
||||
const stored = localStorage.getItem("kanban_nav_width");
|
||||
@@ -227,6 +231,15 @@ export function App() {
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSticker) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setActiveSticker(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [activeSticker]);
|
||||
|
||||
const usersById = useMemo(() => {
|
||||
const m = new Map<string, User>();
|
||||
for (const u of users) m.set(u.id, u);
|
||||
@@ -638,6 +651,73 @@ export function App() {
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const persistStickers = useCallback(async (id: string, stickers: Card["stickers"]) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, stickers } : c)) };
|
||||
});
|
||||
try {
|
||||
await api.updateCardStickers(id, stickers);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleAddSticker = useCallback((cardId: string, x: number, y: number) => {
|
||||
if (!activeSticker) return;
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
const cards = prev.cards.map((c) => {
|
||||
if (c.id !== cardId) return c;
|
||||
const stickers = [...(c.stickers || []), { emoji: activeSticker, x, y }];
|
||||
api.updateCardStickers(cardId, stickers).catch((e) => {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
});
|
||||
return { ...c, stickers };
|
||||
});
|
||||
return { ...prev, cards };
|
||||
});
|
||||
}, [activeSticker, reload]);
|
||||
|
||||
const handleRemoveSticker = useCallback((cardId: string, index: number) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
const cards = prev.cards.map((c) => {
|
||||
if (c.id !== cardId) return c;
|
||||
const stickers = (c.stickers || []).filter((_, i) => i !== index);
|
||||
api.updateCardStickers(cardId, stickers).catch((e) => {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
});
|
||||
return { ...c, stickers };
|
||||
});
|
||||
return { ...prev, cards };
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
const handleMoveSticker = useCallback((cardId: string, index: number, x: number, y: number) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
const cards = prev.cards.map((c) => {
|
||||
if (c.id !== cardId) return c;
|
||||
const stickers = (c.stickers || []).map((s, i) => (i === index ? { ...s, x, y } : s));
|
||||
return { ...c, stickers };
|
||||
});
|
||||
return { ...prev, cards };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCommitSticker = useCallback((cardId: string) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
const card = prev.cards.find((c) => c.id === cardId);
|
||||
if (card) persistStickers(cardId, card.stickers || []);
|
||||
return prev;
|
||||
});
|
||||
}, [persistStickers]);
|
||||
|
||||
const handleShowHistory = useCallback((card: Card) => {
|
||||
modals.open({
|
||||
title: card.title,
|
||||
@@ -843,6 +923,11 @@ export function App() {
|
||||
onShowHistory={handleShowHistory}
|
||||
onToggleCardLock={handleToggleCardLock}
|
||||
onAssignCard={handleAssignCard}
|
||||
activeSticker={activeSticker}
|
||||
onAddSticker={handleAddSticker}
|
||||
onRemoveSticker={handleRemoveSticker}
|
||||
onMoveSticker={handleMoveSticker}
|
||||
onCommitSticker={handleCommitSticker}
|
||||
users={users}
|
||||
usersById={usersById}
|
||||
/>
|
||||
@@ -1013,6 +1098,33 @@ export function App() {
|
||||
setFilterDateTo(t);
|
||||
}}>30d</Button>
|
||||
</Group>
|
||||
<StickerPicker
|
||||
opened={stickerPickerOpen}
|
||||
onClose={() => setStickerPickerOpen(false)}
|
||||
onSelect={(emoji) => setActiveSticker(emoji)}
|
||||
target={
|
||||
<Button
|
||||
size="xs"
|
||||
variant={activeSticker ? "filled" : "default"}
|
||||
color={activeSticker ? "yellow" : undefined}
|
||||
leftSection={<IconMoodSmile size={14} />}
|
||||
onClick={() => setStickerPickerOpen((v) => !v)}
|
||||
>
|
||||
{activeSticker ? `Modo sticker: ${activeSticker}` : "Stickers"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{activeSticker && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconX size={12} />}
|
||||
onClick={() => setActiveSticker(null)}
|
||||
>
|
||||
ESC
|
||||
</Button>
|
||||
)}
|
||||
{filtersActive && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -1060,6 +1172,11 @@ export function App() {
|
||||
onShowHistory={handleShowHistory}
|
||||
onToggleCardLock={handleToggleCardLock}
|
||||
onAssignCard={handleAssignCard}
|
||||
activeSticker={activeSticker}
|
||||
onAddSticker={handleAddSticker}
|
||||
onRemoveSticker={handleRemoveSticker}
|
||||
onMoveSticker={handleMoveSticker}
|
||||
onCommitSticker={handleCommitSticker}
|
||||
users={users}
|
||||
usersById={usersById}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user