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,
|
||||
GripVertical,
|
||||
MoreVertical,
|
||||
NotebookPen,
|
||||
Pencil,
|
||||
Plus,
|
||||
Sparkles,
|
||||
|
|
@ -33,7 +34,7 @@ import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
|||
import { useBoard } from "@/components/board/board-context";
|
||||
import { useBoardView } from "@/components/board/board-view-provider";
|
||||
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 { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||
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 [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [sessionOpen, setSessionOpen] = useState(false);
|
||||
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
||||
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
||||
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||
|
|
@ -270,7 +272,15 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
</h3>
|
||||
</div>
|
||||
<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>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
|
|
@ -480,6 +490,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
|
||||
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
||||
|
||||
<GroupSessionDialog group={group} open={sessionOpen} onOpenChange={setSessionOpen} />
|
||||
|
||||
<TodoAiDialog
|
||||
group={group}
|
||||
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