diff --git a/db.go b/db.go index 91aab22..01e8a43 100644 --- a/db.go +++ b/db.go @@ -27,23 +27,30 @@ type Column struct { CreatedAt string `json:"created_at"` } +type Sticker struct { + Emoji string `json:"emoji"` + X float64 `json:"x"` + Y float64 `json:"y"` +} + type Card struct { - ID string `json:"id"` - Requester string `json:"requester"` - Title string `json:"title"` - Description string `json:"description"` - Color string `json:"color"` - ColumnID string `json:"column_id"` - Position int `json:"position"` - Locked bool `json:"locked"` - AssigneeID *string `json:"assignee_id"` - CompletedAt *string `json:"completed_at"` - DeletedAt *string `json:"deleted_at"` - Tags []string `json:"tags"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - EnteredAt string `json:"entered_at"` - TimeInColumn int64 `json:"time_in_column_ms"` + ID string `json:"id"` + Requester string `json:"requester"` + Title string `json:"title"` + Description string `json:"description"` + Color string `json:"color"` + ColumnID string `json:"column_id"` + Position int `json:"position"` + Locked bool `json:"locked"` + AssigneeID *string `json:"assignee_id"` + CompletedAt *string `json:"completed_at"` + DeletedAt *string `json:"deleted_at"` + Tags []string `json:"tags"` + Stickers []Sticker `json:"stickers"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + EnteredAt string `json:"entered_at"` + TimeInColumn int64 `json:"time_in_column_ms"` } type HistoryEntry struct { @@ -106,6 +113,7 @@ func ensureColumns(conn *sql.DB) error { {"cards", "completed_at", "TEXT"}, {"cards", "deleted_at", "TEXT"}, {"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"}, + {"cards", "stickers", "TEXT NOT NULL DEFAULT '[]'"}, {"card_column_history", "actor_id", "TEXT"}, {"card_lock_history", "actor_id", "TEXT"}, } @@ -195,6 +203,49 @@ func encodeTags(in []string) string { return string(b) } +func parseStickers(s string) []Sticker { + out := []Sticker{} + if s == "" { + return out + } + if err := json.Unmarshal([]byte(s), &out); err != nil { + return []Sticker{} + } + return out +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func normalizeStickers(in []Sticker) []Sticker { + out := make([]Sticker, 0, len(in)) + for _, s := range in { + emoji := strings.TrimSpace(s.Emoji) + if emoji == "" { + continue + } + out = append(out, Sticker{Emoji: emoji, X: clamp01(s.X), Y: clamp01(s.Y)}) + } + return out +} + +func encodeStickers(in []Sticker) string { + b, _ := json.Marshal(normalizeStickers(in)) + return string(b) +} + +func (db *DB) UpdateStickers(id string, stickers []Sticker) error { + _, err := db.conn.Exec(`UPDATE cards SET stickers=?, updated_at=? WHERE id=?`, encodeStickers(stickers), nowRFC3339(), id) + return err +} + func (db *DB) ListAllTags() ([]string, error) { rows, err := db.conn.Query(`SELECT DISTINCT tags FROM cards WHERE deleted_at IS NULL`) if err != nil { @@ -378,7 +429,7 @@ func (db *DB) ReorderColumns(ids []string) error { func (db *DB) ListCardsWithTime() ([]Card, error) { rows, err := db.conn.Query(` - SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.created_at, c.updated_at, + SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.created_at, c.updated_at, h.entered_at FROM cards c LEFT JOIN card_column_history h @@ -399,10 +450,12 @@ func (db *DB) ListCardsWithTime() ([]Card, error) { var completed sql.NullString var deleted sql.NullString var tagsJSON string + var stickersJSON string var locked int - if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil { + if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil { return nil, err } + c.Stickers = parseStickers(stickersJSON) c.Locked = locked != 0 if assignee.Valid && assignee.String != "" { s := assignee.String @@ -441,6 +494,7 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string c := Card{ ID: newID(), Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos, Tags: []string{}, + Stickers: []Sticker{}, CreatedAt: now, UpdatedAt: now, EnteredAt: now, } tx, err := db.conn.Begin() @@ -589,7 +643,7 @@ func (db *DB) PurgeCard(id string) error { // ListDeletedCards returns cards in the trash, newest first. func (db *DB) ListDeletedCards() ([]Card, error) { rows, err := db.conn.Query(` - SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.created_at, c.updated_at + SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.created_at, c.updated_at FROM cards c WHERE c.deleted_at IS NOT NULL ORDER BY c.deleted_at DESC @@ -605,10 +659,12 @@ func (db *DB) ListDeletedCards() ([]Card, error) { var completed sql.NullString var deleted sql.NullString var tagsJSON string + var stickersJSON string var locked int - if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &c.CreatedAt, &c.UpdatedAt); err != nil { + if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } + c.Stickers = parseStickers(stickersJSON) c.Locked = locked != 0 if assignee.Valid && assignee.String != "" { s := assignee.String diff --git a/frontend/package.json b/frontend/package.json index 6e60935..4cf3d46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@mantine/charts": "^9.1.1", "@mantine/core": "^9.0.2", "@mantine/dates": "^9.1.1", @@ -20,6 +22,7 @@ "@mantine/notifications": "^9.0.2", "@tabler/icons-react": "^3.31.0", "dayjs": "^1.11.20", + "emoji-mart": "^5.6.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6cf276c..196aff5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.5) + '@emoji-mart/data': + specifier: ^1.2.1 + version: 1.2.1 + '@emoji-mart/react': + specifier: ^1.1.1 + version: 1.1.1(emoji-mart@5.6.0)(react@19.2.5) '@mantine/charts': specifier: ^9.1.1 version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) @@ -41,6 +47,9 @@ importers: dayjs: specifier: ^1.11.20 version: 1.11.20 + emoji-mart: + specifier: ^5.6.0 + version: 5.6.0 react: specifier: ^19.1.0 version: 19.2.5 @@ -190,6 +199,15 @@ packages: peerDependencies: react: '>=16.8.0' + '@emoji-mart/data@1.2.1': + resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} + + '@emoji-mart/react@1.1.1': + resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==} + peerDependencies: + emoji-mart: ^5.2 + react: ^16.8 || ^17 || ^18 + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -781,6 +799,9 @@ packages: electron-to-chromium@1.5.351: resolution: {integrity: sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==} + emoji-mart@5.6.0: + resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1491,6 +1512,13 @@ snapshots: react: 19.2.5 tslib: 2.8.1 + '@emoji-mart/data@1.2.1': {} + + '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.5)': + dependencies: + emoji-mart: 5.6.0 + react: 19.2.5 + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -1943,6 +1971,8 @@ snapshots: electron-to-chromium@1.5.351: {} + emoji-mart@5.6.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ffa0c56..d429804 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [filterDateTo, setFilterDateTo] = useState(null); + const [stickerPickerOpen, setStickerPickerOpen] = useState(false); + const [activeSticker, setActiveSticker] = useState(null); const [navOpen, setNavOpen] = useState(false); const [navWidth, setNavWidth] = useState(() => { 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(); 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 + setStickerPickerOpen(false)} + onSelect={(emoji) => setActiveSticker(emoji)} + target={ + + } + /> + {activeSticker && ( + + )} {filtersActive && (