Organize/components/board/group-session-dialog.tsx

482 lines
16 KiB
TypeScript

"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>
);
}