482 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|