From aa6a2e1cfd40dd279a0e6ef07d3731e8d39a55d7 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 12 Aug 2026 15:01:54 -0600 Subject: [PATCH] feat: add AI provider config and todo completion timestamps - Add `AiSettingsCard` component and server actions for configuring an OpenAI-compatible AI endpoint (e.g. OpenWebUI) from the admin panel - Introduce `AiSettings` Prisma model with masked API key display and connection testing UI - Track `completedAt` on todos with a migration and backfill for existing completed items - Surface `createdAt`, `updatedAt`, and `completedAt` in the todo edit dialog via a new `formatDateTime` utility - Update `TodoDTO`, `getBoard`, `createTodo`, `toggleTodo`, and the board context to carry and optimistically update timestamp fields - Add `Select` UI component built on `@base-ui/react` --- app/(app)/admin/page.tsx | 7 +- components/admin/ai-settings-card.tsx | 136 ++++++++++++ components/board/board-context.tsx | 16 +- components/board/todo-edit-dialog.tsx | 10 + components/ui/select.tsx | 201 ++++++++++++++++++ lib/actions/ai.ts | 112 ++++++++++ lib/actions/todos.ts | 8 +- lib/ai-settings.ts | 70 ++++++ lib/board.ts | 3 + lib/format.ts | 11 + .../migration.sql | 8 + .../migration.sql | 13 ++ prisma/schema.prisma | 24 ++- types/board.ts | 6 + 14 files changed, 620 insertions(+), 5 deletions(-) create mode 100644 components/admin/ai-settings-card.tsx create mode 100644 components/ui/select.tsx create mode 100644 lib/actions/ai.ts create mode 100644 lib/ai-settings.ts create mode 100644 lib/format.ts create mode 100644 prisma/migrations/20260814000000_add_todo_completed_at/migration.sql create mode 100644 prisma/migrations/20260815000000_add_ai_settings/migration.sql diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx index 3f0d5b1..09c4866 100644 --- a/app/(app)/admin/page.tsx +++ b/app/(app)/admin/page.tsx @@ -4,20 +4,23 @@ import { auth } from "@/auth"; import { prisma } from "@/lib/db"; import { Role } from "@/lib/generated/prisma/enums"; import { getSignupMode } from "@/lib/settings"; +import { getAiSettingsView } from "@/lib/ai-settings"; import { AdminUserTable, type AdminUserRow } from "@/components/admin/admin-user-table"; import { SignupModeSettings } from "@/components/admin/signup-mode-settings"; +import { AiSettingsCard } from "@/components/admin/ai-settings-card"; export default async function AdminPage() { const session = await auth(); if (!session?.user) redirect("/login"); if (session.user.role !== Role.ADMIN) redirect("/"); - const [users, signupMode] = await Promise.all([ + const [users, signupMode, aiSettings] = await Promise.all([ prisma.user.findMany({ orderBy: { createdAt: "asc" }, select: { id: true, name: true, email: true, role: true, createdAt: true }, }), getSignupMode(), + getAiSettingsView(), ]); const rows: AdminUserRow[] = users.map((user) => ({ @@ -44,6 +47,8 @@ export default async function AdminPage() { + + ); } diff --git a/components/admin/ai-settings-card.tsx b/components/admin/ai-settings-card.tsx new file mode 100644 index 0000000..55bc82b --- /dev/null +++ b/components/admin/ai-settings-card.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { testAiConnection, saveAiConfig } from "@/lib/actions/ai"; +import type { AiSettingsView } from "@/lib/ai-settings"; + +/** Lets an admin point the site at an OpenAI-compatible endpoint (e.g. + * OpenWebUI): a URL, a key, and -- once a test confirms it works -- which + * of the provider's models to use for AI generation features. */ +export function AiSettingsCard({ initial }: { initial: AiSettingsView }) { + const [apiUrl, setApiUrl] = useState(initial.apiUrl ?? ""); + // Never prefilled with the real key -- the server never sends it back. + // A blank field on save means "keep the key that's already stored". + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(initial.model ?? ""); + // Seeded with just the saved model (if any) so the picker isn't empty + // on first load -- a fresh "Test connection" replaces this with the + // provider's real, current list. + const [models, setModels] = useState(initial.model ? [initial.model] : []); + const [hasApiKey, setHasApiKey] = useState(initial.hasApiKey); + const [testing, setTesting] = useState(false); + const [saving, setSaving] = useState(false); + + async function handleTest() { + setTesting(true); + const result = await testAiConnection(apiUrl, apiKey); + setTesting(false); + if (result.error) { + toast.error(result.error); + return; + } + const found = result.models ?? []; + setModels(found); + setModel((prev) => (found.includes(prev) ? prev : found[0] ?? "")); + toast.success(`Connected -- found ${found.length} model${found.length === 1 ? "" : "s"}.`); + } + + async function handleSave() { + setSaving(true); + const result = await saveAiConfig({ apiUrl, apiKey, model }); + setSaving(false); + if (result.error) { + toast.error(result.error); + return; + } + if (apiKey.trim()) setHasApiKey(true); + setApiKey(""); // saved -- a blank field means "unchanged" from here on + toast.success("AI settings saved."); + } + + return ( + + + AI generation + + Connect an OpenAI-compatible endpoint (e.g. OpenWebUI) to power AI generation features + on the site. + + + +
+ + setApiUrl(e.target.value)} + placeholder="https://your-openwebui-host/api/v1" + /> +
+ +
+ + setApiKey(e.target.value)} + placeholder={hasApiKey ? "Unchanged -- leave blank to keep the saved key" : "sk-…"} + autoComplete="off" + /> +
+ + + +
+ + +
+
+ + + +
+ ); +} diff --git a/components/board/board-context.tsx b/components/board/board-context.tsx index 84fb1d6..ca96e47 100644 --- a/components/board/board-context.tsx +++ b/components/board/board-context.tsx @@ -308,9 +308,13 @@ export function BoardProvider({ ) => { try { await updateTodoAction(todoId, data); + // Approximates the server's own `updatedAt` (set by the same write, + // a moment later) closely enough for display purposes, without + // waiting on a round trip just to read it back. + const updatedAt = new Date().toISOString(); updateGroupInState(categoryId, groupId, (g) => ({ ...g, - todos: g.todos.map((t) => (t.id === todoId ? { ...t, ...data } : t)), + todos: g.todos.map((t) => (t.id === todoId ? { ...t, ...data, updatedAt } : t)), })); return true; } catch { @@ -323,13 +327,21 @@ export function BoardProvider({ const toggleTodoDone = useCallback( async (todoId: string, groupId: string, categoryId: string, completed: boolean) => { + const now = new Date().toISOString(); let prevTodos: TodoDTO[] = []; updateCategory(categoryId, (c) => ({ ...c, groups: c.groups.map((g) => { if (g.id !== groupId) return g; prevTodos = g.todos; - return { ...g, todos: g.todos.map((t) => (t.id === todoId ? { ...t, completed } : t)) }; + return { + ...g, + todos: g.todos.map((t) => + t.id === todoId + ? { ...t, completed, completedAt: completed ? now : null, updatedAt: now } + : t + ), + }; }), })); try { diff --git a/components/board/todo-edit-dialog.tsx b/components/board/todo-edit-dialog.tsx index e23597c..69abff8 100644 --- a/components/board/todo-edit-dialog.tsx +++ b/components/board/todo-edit-dialog.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/dialog"; import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets"; import { useBoard } from "@/components/board/board-context"; +import { formatDateTime } from "@/lib/format"; import type { TodoDTO } from "@/types/board"; const TITLE_MAX = 20; @@ -125,6 +126,15 @@ export function TodoEditDialog({ )} + +
+ Created {formatDateTime(todo.createdAt)} + Updated {formatDateTime(todo.updatedAt)} + {todo.completed && todo.completedAt && ( + Completed {formatDateTime(todo.completedAt)} + )} +
+