Replace notes dialog with group session dialog
- Combine group notes and to-dos in one editing surface - Add debounced autosave with explicit save and flush-on-close - Preserve in-progress local edits against cross-device and AI updates
This commit is contained in:
parent
323589f281
commit
ddba881380
|
|
@ -9,6 +9,7 @@ import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
|
NotebookPen,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
|
@ -33,7 +34,7 @@ import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
||||||
import { useBoard } from "@/components/board/board-context";
|
import { useBoard } from "@/components/board/board-context";
|
||||||
import { useBoardView } from "@/components/board/board-view-provider";
|
import { useBoardView } from "@/components/board/board-view-provider";
|
||||||
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
|
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
|
||||||
import { NotesDialog } from "@/components/board/notes-dialog";
|
import { GroupSessionDialog } from "@/components/board/group-session-dialog";
|
||||||
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
||||||
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||||
import { TodoCreatePopover } from "@/components/board/todo-create-popover";
|
import { TodoCreatePopover } from "@/components/board/todo-create-popover";
|
||||||
|
|
@ -97,6 +98,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
|
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [sessionOpen, setSessionOpen] = useState(false);
|
||||||
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
||||||
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
||||||
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||||
|
|
@ -270,7 +272,15 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center">
|
||||||
<NotesDialog group={group} />
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
aria-label={`Notes for ${group.title}`}
|
||||||
|
onClick={() => setSessionOpen(true)}
|
||||||
|
>
|
||||||
|
<NotebookPen className="size-4" />
|
||||||
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
|
|
@ -480,6 +490,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
|
|
||||||
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
|
||||||
|
<GroupSessionDialog group={group} open={sessionOpen} onOpenChange={setSessionOpen} />
|
||||||
|
|
||||||
<TodoAiDialog
|
<TodoAiDialog
|
||||||
group={group}
|
group={group}
|
||||||
open={todoAiOpen}
|
open={todoAiOpen}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,481 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Pencil, Plus, Sparkles, Trash2, X, XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
||||||
|
import { colorModeFromTheme, isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
||||||
|
import { getBrighterColor } from "@/lib/colors";
|
||||||
|
import { useBoard } from "@/components/board/board-context";
|
||||||
|
import { TodoCheckbox } from "@/components/board/todo-checkbox";
|
||||||
|
import { GroupAiDialog } from "@/components/board/group-ai-dialog";
|
||||||
|
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
||||||
|
import { useAutosave } from "@/hooks/use-autosave";
|
||||||
|
import type { GroupDTO, TodoDTO } from "@/types/board";
|
||||||
|
|
||||||
|
const TITLE_MAX = 20;
|
||||||
|
|
||||||
|
type TodoDraft = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
details: string;
|
||||||
|
completed: boolean;
|
||||||
|
isNew: boolean;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function tempId(): string {
|
||||||
|
return `temp-${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDrafts(todos: TodoDTO[]): TodoDraft[] {
|
||||||
|
return todos.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
details: t.details ?? "",
|
||||||
|
completed: t.completed,
|
||||||
|
isNew: false,
|
||||||
|
order: t.order,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges fresh server todos into the local draft list without clobbering
|
||||||
|
* anything the user has mid-edit: a draft whose id is in `dirtyIds` (still
|
||||||
|
* unsaved, or mid-save) is kept as-is; everything else is refreshed from
|
||||||
|
* the server, including dropping drafts the server no longer has and
|
||||||
|
* picking up server todos not yet represented locally.
|
||||||
|
*/
|
||||||
|
function reconcileTodoDrafts(
|
||||||
|
current: TodoDraft[],
|
||||||
|
serverTodos: TodoDTO[],
|
||||||
|
dirtyIds: Set<string>
|
||||||
|
): TodoDraft[] {
|
||||||
|
const currentById = new Map(current.map((d) => [d.id, d]));
|
||||||
|
const merged: TodoDraft[] = [];
|
||||||
|
|
||||||
|
for (const todo of serverTodos) {
|
||||||
|
const existing = currentById.get(todo.id);
|
||||||
|
if (existing && dirtyIds.has(todo.id)) {
|
||||||
|
merged.push(existing);
|
||||||
|
} else {
|
||||||
|
merged.push({
|
||||||
|
id: todo.id,
|
||||||
|
title: todo.title,
|
||||||
|
details: todo.details ?? "",
|
||||||
|
completed: todo.completed,
|
||||||
|
isNew: false,
|
||||||
|
order: todo.order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local-only new rows (not yet persisted) and any dirty draft the server
|
||||||
|
// list somehow doesn't have yet both survive untouched.
|
||||||
|
for (const draft of current) {
|
||||||
|
if (draft.isNew || (dirtyIds.has(draft.id) && !serverTodos.some((t) => t.id === draft.id))) {
|
||||||
|
if (!merged.some((d) => d.id === draft.id)) merged.push(draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupSessionDialog({
|
||||||
|
group,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
group: GroupDTO;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { saveNote, addTodo, editTodo, toggleTodoDone, removeTodo } = useBoard();
|
||||||
|
const colorMode = colorModeFromTheme();
|
||||||
|
const isDark = isDarkTheme();
|
||||||
|
const color = useGroupColor(group.color);
|
||||||
|
const accentColor = isDark ? color.dark : color.light;
|
||||||
|
const todoTextColor = getBrighterColor(accentColor, isDark ? "dark" : "light");
|
||||||
|
|
||||||
|
const [noteDraft, setNoteDraft] = useState(group.noteContent);
|
||||||
|
const [todoDrafts, setTodoDrafts] = useState<TodoDraft[]>(() => toDrafts(group.todos));
|
||||||
|
const [aiOpen, setAiOpen] = useState(false);
|
||||||
|
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
||||||
|
const [savingAll, setSavingAll] = useState(false);
|
||||||
|
|
||||||
|
const rowFlushersRef = useRef(new Map<string, () => Promise<void>>());
|
||||||
|
const dirtyRowIdsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
const noteAutosave = useAutosave(
|
||||||
|
noteDraft,
|
||||||
|
useCallback((v: string) => saveNote(group.id, group.categoryId, v), [group.id, group.categoryId, saveNote])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed fresh drafts every time the session opens, so a prior open's
|
||||||
|
// leftover local-only "new" rows never leak into the next session.
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
if (next) {
|
||||||
|
setNoteDraft(group.noteContent);
|
||||||
|
setTodoDrafts(toDrafts(group.todos));
|
||||||
|
} else {
|
||||||
|
// Fire-and-forget: close immediately, flush any pending autosaves in
|
||||||
|
// the background. board-context's mutators already toast on failure,
|
||||||
|
// and their state lives above this dialog so it's safe to keep
|
||||||
|
// running after this component unmounts.
|
||||||
|
void handleSaveAll();
|
||||||
|
}
|
||||||
|
onOpenChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picks up remote changes (cross-device poll, an accepted "Ask AI" edit)
|
||||||
|
// while this session is open, but never while the corresponding field is
|
||||||
|
// dirty or mid-save -- otherwise a poll landing mid-keystroke would wipe
|
||||||
|
// out what the user just typed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (!noteAutosave.isDirty && !noteAutosave.isSaving) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing from an external source (cross-device poll / accepted AI edit), same pattern as theme-provider.tsx
|
||||||
|
setNoteDraft(group.noteContent);
|
||||||
|
}
|
||||||
|
}, [group.noteContent, open, noteAutosave.isDirty, noteAutosave.isSaving]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTodoDrafts((prev) => reconcileTodoDrafts(prev, group.todos, dirtyRowIdsRef.current));
|
||||||
|
}, [group.todos, open]);
|
||||||
|
|
||||||
|
function updateTodoDraft(id: string, patch: Partial<TodoDraft>) {
|
||||||
|
setTodoDrafts((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTodoDraftLocalOnly(id: string) {
|
||||||
|
setTodoDrafts((prev) => prev.filter((d) => d.id !== id));
|
||||||
|
dirtyRowIdsRef.current.delete(id);
|
||||||
|
rowFlushersRef.current.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerRowFlush(id: string, flush: () => Promise<void>) {
|
||||||
|
rowFlushersRef.current.set(id, flush);
|
||||||
|
return () => {
|
||||||
|
rowFlushersRef.current.delete(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRowDirty(id: string, dirty: boolean) {
|
||||||
|
if (dirty) dirtyRowIdsRef.current.add(id);
|
||||||
|
else dirtyRowIdsRef.current.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddTodoCreated(tempIdToRemove: string) {
|
||||||
|
removeTodoDraftLocalOnly(tempIdToRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEmptyTodoDraft() {
|
||||||
|
setTodoDrafts((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: tempId(), title: "", details: "", completed: false, isNew: true, order: prev.length },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAll() {
|
||||||
|
setSavingAll(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
noteAutosave.flush(),
|
||||||
|
...Array.from(rowFlushersRef.current.values()).map((f) => f()),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setSavingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
data-color-mode={colorMode}
|
||||||
|
showCloseButton={false}
|
||||||
|
className="fixed top-[2vh] left-1/2 h-[96vh] max-h-[96vh] w-[calc(100%-1rem)] max-w-[calc(100%-1rem)] sm:max-w-5xl -translate-x-1/2 translate-y-0 flex flex-col gap-0 overflow-hidden p-0"
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b p-4">
|
||||||
|
<DialogTitle className="min-w-0 truncate">{group.title}</DialogTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{savingAll || noteAutosave.isSaving ? "Saving…" : "Saved"}
|
||||||
|
</span>
|
||||||
|
<DialogClose
|
||||||
|
render={<Button variant="ghost" size="icon-sm" aria-label="Close" />}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-6 overflow-y-auto overscroll-contain p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Notes</Label>
|
||||||
|
<MDEditor
|
||||||
|
value={noteDraft}
|
||||||
|
onChange={(v) => setNoteDraft(v ?? "")}
|
||||||
|
height={280}
|
||||||
|
preview="edit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">To-dos</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{todoDrafts.map((draft) => (
|
||||||
|
<SessionTodoRow
|
||||||
|
key={draft.id}
|
||||||
|
draft={draft}
|
||||||
|
groupId={group.id}
|
||||||
|
categoryId={group.categoryId}
|
||||||
|
accentColor={accentColor}
|
||||||
|
todoTextColor={todoTextColor}
|
||||||
|
isDark={isDark}
|
||||||
|
onChangeLocal={(patch) => updateTodoDraft(draft.id, patch)}
|
||||||
|
onRemoveLocalOnly={() => removeTodoDraftLocalOnly(draft.id)}
|
||||||
|
onCreated={() => handleAddTodoCreated(draft.id)}
|
||||||
|
onDirtyChange={(dirty) => setRowDirty(draft.id, dirty)}
|
||||||
|
registerFlush={registerRowFlush}
|
||||||
|
addTodo={addTodo}
|
||||||
|
editTodo={editTodo}
|
||||||
|
toggleTodoDone={toggleTodoDone}
|
||||||
|
removeTodo={removeTodo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{todoDrafts.length === 0 && (
|
||||||
|
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||||
|
No to-dos yet. Add one below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t bg-muted/50 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" className="gap-2" onClick={addEmptyTodoDraft}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add To-Do
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="gap-2" onClick={() => setTodoAiOpen(true)}>
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
Add To-Dos with AI
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="gap-2" onClick={() => setAiOpen(true)}>
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
Ask AI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSaveAll} disabled={savingAll}>
|
||||||
|
{savingAll ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} />
|
||||||
|
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionTodoRow({
|
||||||
|
draft,
|
||||||
|
groupId,
|
||||||
|
categoryId,
|
||||||
|
accentColor,
|
||||||
|
todoTextColor,
|
||||||
|
isDark,
|
||||||
|
onChangeLocal,
|
||||||
|
onRemoveLocalOnly,
|
||||||
|
onCreated,
|
||||||
|
onDirtyChange,
|
||||||
|
registerFlush,
|
||||||
|
addTodo,
|
||||||
|
editTodo,
|
||||||
|
toggleTodoDone,
|
||||||
|
removeTodo,
|
||||||
|
}: {
|
||||||
|
draft: TodoDraft;
|
||||||
|
groupId: string;
|
||||||
|
categoryId: string;
|
||||||
|
accentColor: string;
|
||||||
|
todoTextColor: string;
|
||||||
|
isDark: boolean;
|
||||||
|
onChangeLocal: (patch: Partial<TodoDraft>) => void;
|
||||||
|
onRemoveLocalOnly: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
onDirtyChange: (dirty: boolean) => void;
|
||||||
|
registerFlush: (id: string, flush: () => Promise<void>) => () => void;
|
||||||
|
addTodo: ReturnType<typeof useBoard>["addTodo"];
|
||||||
|
editTodo: ReturnType<typeof useBoard>["editTodo"];
|
||||||
|
toggleTodoDone: ReturnType<typeof useBoard>["toggleTodoDone"];
|
||||||
|
removeTodo: ReturnType<typeof useBoard>["removeTodo"];
|
||||||
|
}) {
|
||||||
|
const [editingDetails, setEditingDetails] = useState(false);
|
||||||
|
// Guards against creating the same new to-do twice if title/details both
|
||||||
|
// become dirty in the same tick (title autosave and details autosave
|
||||||
|
// would otherwise both try to create it).
|
||||||
|
const creatingRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
|
const createIfNeeded = useCallback(async (): Promise<void> => {
|
||||||
|
if (!draft.isNew) return;
|
||||||
|
const title = draft.title.trim();
|
||||||
|
if (!title) return;
|
||||||
|
if (creatingRef.current) {
|
||||||
|
await creatingRef.current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creatingRef.current = (async () => {
|
||||||
|
const ok = await addTodo(groupId, categoryId, title, draft.details.trim() || undefined);
|
||||||
|
if (ok) onCreated();
|
||||||
|
})();
|
||||||
|
await creatingRef.current;
|
||||||
|
}, [draft.isNew, draft.title, draft.details, groupId, categoryId, addTodo, onCreated]);
|
||||||
|
|
||||||
|
const saveTitle = useCallback(
|
||||||
|
async (title: string) => {
|
||||||
|
if (draft.isNew) {
|
||||||
|
await createIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!title.trim()) return;
|
||||||
|
await editTodo(draft.id, groupId, categoryId, { title: title.trim() });
|
||||||
|
},
|
||||||
|
[draft.isNew, draft.id, groupId, categoryId, editTodo, createIfNeeded]
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveDetails = useCallback(
|
||||||
|
async (details: string) => {
|
||||||
|
if (draft.isNew) {
|
||||||
|
await createIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await editTodo(draft.id, groupId, categoryId, { details: details.trim() || null });
|
||||||
|
},
|
||||||
|
[draft.isNew, draft.id, groupId, categoryId, editTodo, createIfNeeded]
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleAutosave = useAutosave(draft.title, saveTitle);
|
||||||
|
const detailsAutosave = useAutosave(draft.details, saveDetails);
|
||||||
|
|
||||||
|
const dirty = titleAutosave.isDirty || titleAutosave.isSaving || detailsAutosave.isDirty || detailsAutosave.isSaving;
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange(dirty);
|
||||||
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = async () => {
|
||||||
|
if (draft.isNew && draft.title.trim()) {
|
||||||
|
await createIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all([titleAutosave.flush(), detailsAutosave.flush()]);
|
||||||
|
};
|
||||||
|
return registerFlush(draft.id, flush);
|
||||||
|
}, [draft.id, draft.isNew, draft.title, createIfNeeded, titleAutosave, detailsAutosave, registerFlush]);
|
||||||
|
|
||||||
|
function handleToggle(next: boolean) {
|
||||||
|
onChangeLocal({ completed: next });
|
||||||
|
if (!draft.isNew) {
|
||||||
|
void toggleTodoDone(draft.id, groupId, categoryId, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (draft.isNew) {
|
||||||
|
onRemoveLocalOnly();
|
||||||
|
} else {
|
||||||
|
onRemoveLocalOnly();
|
||||||
|
void removeTodo(draft.id, groupId, categoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkbox = (
|
||||||
|
<TodoCheckbox
|
||||||
|
checked={draft.completed}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
accentColor={accentColor}
|
||||||
|
isDark={isDark}
|
||||||
|
className="mt-1"
|
||||||
|
aria-label={`Mark "${draft.title || "new to-do"}" ${draft.completed ? "incomplete" : "complete"}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{draft.isNew ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={<span className="mt-1 inline-flex pointer-events-none opacity-50" />}
|
||||||
|
>
|
||||||
|
{checkbox}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Save a title first</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
checkbox
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
value={draft.title}
|
||||||
|
maxLength={TITLE_MAX}
|
||||||
|
placeholder="To-do title"
|
||||||
|
autoFocus={draft.isNew && !draft.title}
|
||||||
|
onChange={(e) => onChangeLocal({ title: e.target.value })}
|
||||||
|
style={draft.completed ? undefined : { color: todoTextColor }}
|
||||||
|
className={cn("flex-1", draft.completed && "text-muted-foreground line-through")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={handleDelete}
|
||||||
|
aria-label={`Delete "${draft.title || "new to-do"}"`}
|
||||||
|
>
|
||||||
|
{draft.isNew ? <X className="size-4" /> : <Trash2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Details</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 gap-1 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={() => setEditingDetails((v) => !v)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
{editingDetails ? "Preview" : "Edit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingDetails ? (
|
||||||
|
<MDEditor
|
||||||
|
value={draft.details}
|
||||||
|
onChange={(v) => onChangeLocal({ details: v ?? "" })}
|
||||||
|
height={160}
|
||||||
|
preview="edit"
|
||||||
|
/>
|
||||||
|
) : draft.details ? (
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded-md border p-2">
|
||||||
|
<MarkdownPreview source={draft.details} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-md border border-dashed p-3 text-center text-xs text-muted-foreground">
|
||||||
|
No details yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { NotebookPen, Pencil, Sparkles } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
|
||||||
import { useBoard } from "@/components/board/board-context";
|
|
||||||
import { GroupAiDialog } from "@/components/board/group-ai-dialog";
|
|
||||||
import type { GroupDTO } from "@/types/board";
|
|
||||||
|
|
||||||
export function NotesDialog({ group }: { group: GroupDTO }) {
|
|
||||||
const { saveNote } = useBoard();
|
|
||||||
const colorMode = colorModeFromTheme();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [draft, setDraft] = useState(group.noteContent);
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const [aiOpen, setAiOpen] = useState(false);
|
|
||||||
|
|
||||||
function handleOpenChange(next: boolean) {
|
|
||||||
setOpen(next);
|
|
||||||
if (next) {
|
|
||||||
setDraft(group.noteContent);
|
|
||||||
setEditing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The "Ask AI" dialog can update this group's notes (via saveNote) while
|
|
||||||
// this dialog stays open behind it. Pick up that change so the accepted
|
|
||||||
// AI notes show up here immediately instead of the stale pre-AI draft.
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && !editing) {
|
|
||||||
setDraft(group.noteContent);
|
|
||||||
}
|
|
||||||
}, [group.noteContent, open, editing]);
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
setPending(true);
|
|
||||||
const ok = await saveNote(group.id, group.categoryId, draft);
|
|
||||||
setPending(false);
|
|
||||||
if (ok) setEditing(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-7 shrink-0"
|
|
||||||
aria-label={`Notes for ${group.title}`}
|
|
||||||
onClick={() => handleOpenChange(true)}
|
|
||||||
>
|
|
||||||
<NotebookPen className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<DialogContent className="sm:max-w-2xl" data-color-mode={colorMode}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{group.title} — Notes</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{editing ? (
|
|
||||||
<MDEditor value={draft} onChange={(v) => setDraft(v ?? "")} height={360} />
|
|
||||||
) : draft ? (
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto rounded-md border p-4">
|
|
||||||
<MarkdownPreview source={draft} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
||||||
No notes yet. Click Edit to add some.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
{editing ? (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setEditing(false)} disabled={pending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={pending}>
|
|
||||||
{pending ? "Saving…" : "Save"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setAiOpen(true)} className="gap-2">
|
|
||||||
<Sparkles className="size-4" />
|
|
||||||
Ask AI
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => setEditing(true)} className="gap-2">
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
// "A few seconds" per the group session's live-editing requirement --
|
||||||
|
// short enough to feel live, long enough not to fire a save on every
|
||||||
|
// keystroke. Deliberately well under board-context's 30s cross-device
|
||||||
|
// poll interval, so a local edit always settles before the poll could
|
||||||
|
// otherwise consider overwriting it.
|
||||||
|
export const AUTOSAVE_DELAY_MS = 2500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced autosave for a single field's value. Call `onSave` a beat
|
||||||
|
* after `value` stops changing, and again immediately via `flush()` (used
|
||||||
|
* by a manual "Save" button and by flush-on-close) -- both paths funnel
|
||||||
|
* through the same `runSave`, so there's no separate "save now" logic to
|
||||||
|
* keep in sync with the debounced one.
|
||||||
|
*
|
||||||
|
* `onSave` must be one of the board-context mutators (e.g. `saveNote`,
|
||||||
|
* `editTodo`), never a raw server action directly -- those mutators already
|
||||||
|
* mark the write as in-flight (see `track()` in board-context.tsx) so the
|
||||||
|
* app's cross-device poll doesn't clobber it mid-save.
|
||||||
|
*/
|
||||||
|
export function useAutosave<T>(
|
||||||
|
value: T,
|
||||||
|
onSave: (value: T) => Promise<boolean | void>,
|
||||||
|
delayMs = AUTOSAVE_DELAY_MS
|
||||||
|
): { flush: () => Promise<void>; isDirty: boolean; isSaving: boolean } {
|
||||||
|
const [savedValue, setSavedValue] = useState(value);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const onSaveRef = useRef(onSave);
|
||||||
|
const savingPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSaveRef.current = onSave;
|
||||||
|
}, [onSave]);
|
||||||
|
|
||||||
|
const runSave = useCallback((v: T) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
const promise = (async () => {
|
||||||
|
await onSaveRef.current(v);
|
||||||
|
setSavedValue(v);
|
||||||
|
setIsSaving(false);
|
||||||
|
})();
|
||||||
|
savingPromiseRef.current = promise;
|
||||||
|
return promise;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value === savedValue) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void runSave(value);
|
||||||
|
}, delayMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, savedValue, delayMs, runSave]);
|
||||||
|
|
||||||
|
const flush = useCallback(async () => {
|
||||||
|
if (value !== savedValue) {
|
||||||
|
await runSave(value);
|
||||||
|
} else if (savingPromiseRef.current) {
|
||||||
|
await savingPromiseRef.current;
|
||||||
|
}
|
||||||
|
}, [value, savedValue, runSave]);
|
||||||
|
|
||||||
|
return { flush, isDirty: value !== savedValue, isSaving };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue