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`
This commit is contained in:
parent
42f9785624
commit
aa6a2e1cfd
|
|
@ -4,20 +4,23 @@ import { auth } from "@/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { Role } from "@/lib/generated/prisma/enums";
|
import { Role } from "@/lib/generated/prisma/enums";
|
||||||
import { getSignupMode } from "@/lib/settings";
|
import { getSignupMode } from "@/lib/settings";
|
||||||
|
import { getAiSettingsView } from "@/lib/ai-settings";
|
||||||
import { AdminUserTable, type AdminUserRow } from "@/components/admin/admin-user-table";
|
import { AdminUserTable, type AdminUserRow } from "@/components/admin/admin-user-table";
|
||||||
import { SignupModeSettings } from "@/components/admin/signup-mode-settings";
|
import { SignupModeSettings } from "@/components/admin/signup-mode-settings";
|
||||||
|
import { AiSettingsCard } from "@/components/admin/ai-settings-card";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
if (session.user.role !== Role.ADMIN) redirect("/");
|
if (session.user.role !== Role.ADMIN) redirect("/");
|
||||||
|
|
||||||
const [users, signupMode] = await Promise.all([
|
const [users, signupMode, aiSettings] = await Promise.all([
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
select: { id: true, name: true, email: true, role: true, createdAt: true },
|
select: { id: true, name: true, email: true, role: true, createdAt: true },
|
||||||
}),
|
}),
|
||||||
getSignupMode(),
|
getSignupMode(),
|
||||||
|
getAiSettingsView(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const rows: AdminUserRow[] = users.map((user) => ({
|
const rows: AdminUserRow[] = users.map((user) => ({
|
||||||
|
|
@ -44,6 +47,8 @@ export default async function AdminPage() {
|
||||||
<AdminUserTable initialUsers={rows} currentUserId={session.user.id} />
|
<AdminUserTable initialUsers={rows} currentUserId={session.user.id} />
|
||||||
|
|
||||||
<SignupModeSettings initialMode={signupMode} />
|
<SignupModeSettings initialMode={signupMode} />
|
||||||
|
|
||||||
|
<AiSettingsCard initial={aiSettings} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string[]>(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 (
|
||||||
|
<Card className="max-w-3xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>AI generation</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Connect an OpenAI-compatible endpoint (e.g. OpenWebUI) to power AI generation features
|
||||||
|
on the site.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ai-api-url">API URL</Label>
|
||||||
|
<Input
|
||||||
|
id="ai-api-url"
|
||||||
|
value={apiUrl}
|
||||||
|
onChange={(e) => setApiUrl(e.target.value)}
|
||||||
|
placeholder="https://your-openwebui-host/api/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ai-api-key">API key</Label>
|
||||||
|
<Input
|
||||||
|
id="ai-api-key"
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder={hasApiKey ? "Unchanged -- leave blank to keep the saved key" : "sk-…"}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" onClick={handleTest} disabled={testing || !apiUrl.trim()}>
|
||||||
|
{testing ? "Testing…" : "Test connection"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ai-model">Model</Label>
|
||||||
|
<Select
|
||||||
|
value={model}
|
||||||
|
onValueChange={(value) => setModel(value ?? "")}
|
||||||
|
disabled={models.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="ai-model" className="w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={models.length === 0 ? "Test the connection to list models" : "Choose a model"}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{models.map((m) => (
|
||||||
|
<SelectItem key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !apiUrl.trim() || !model}>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -308,9 +308,13 @@ export function BoardProvider({
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await updateTodoAction(todoId, data);
|
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) => ({
|
updateGroupInState(categoryId, groupId, (g) => ({
|
||||||
...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;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -323,13 +327,21 @@ export function BoardProvider({
|
||||||
|
|
||||||
const toggleTodoDone = useCallback(
|
const toggleTodoDone = useCallback(
|
||||||
async (todoId: string, groupId: string, categoryId: string, completed: boolean) => {
|
async (todoId: string, groupId: string, categoryId: string, completed: boolean) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
let prevTodos: TodoDTO[] = [];
|
let prevTodos: TodoDTO[] = [];
|
||||||
updateCategory(categoryId, (c) => ({
|
updateCategory(categoryId, (c) => ({
|
||||||
...c,
|
...c,
|
||||||
groups: c.groups.map((g) => {
|
groups: c.groups.map((g) => {
|
||||||
if (g.id !== groupId) return g;
|
if (g.id !== groupId) return g;
|
||||||
prevTodos = g.todos;
|
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 {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
||||||
import { useBoard } from "@/components/board/board-context";
|
import { useBoard } from "@/components/board/board-context";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
import type { TodoDTO } from "@/types/board";
|
import type { TodoDTO } from "@/types/board";
|
||||||
|
|
||||||
const TITLE_MAX = 20;
|
const TITLE_MAX = 20;
|
||||||
|
|
@ -125,6 +126,15 @@ export function TodoEditDialog({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t pt-3 text-xs text-muted-foreground">
|
||||||
|
<span>Created {formatDateTime(todo.createdAt)}</span>
|
||||||
|
<span>Updated {formatDateTime(todo.updatedAt)}</span>
|
||||||
|
{todo.completed && todo.completedAt && (
|
||||||
|
<span>Completed {formatDateTime(todo.completedAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="sm:justify-between">
|
<DialogFooter className="sm:justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
import { requireAdmin } from "@/lib/auth-helpers";
|
||||||
|
import { getAiCredentials, saveAiSettings } from "@/lib/ai-settings";
|
||||||
|
|
||||||
|
export type TestAiConnectionResult = { error?: string; models?: string[] };
|
||||||
|
|
||||||
|
/** Builds the provider's `/models` endpoint from an admin-entered base URL. */
|
||||||
|
function buildModelsUrl(apiUrl: string): URL {
|
||||||
|
const withTrailingSlash = apiUrl.endsWith("/") ? apiUrl : `${apiUrl}/`;
|
||||||
|
return new URL("models", withTrailingSlash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractModelIds(body: unknown): string[] {
|
||||||
|
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : undefined;
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
return data
|
||||||
|
.map((entry) => (entry && typeof entry === "object" ? (entry as { id?: unknown }).id : undefined))
|
||||||
|
.filter((id): id is string => typeof id === "string")
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the given (not-necessarily-saved-yet) endpoint's OpenAI-compatible
|
||||||
|
* `/models` list, so an admin can verify a URL/key pair actually works --
|
||||||
|
* and get real model ids to pick from -- before committing to it.
|
||||||
|
*/
|
||||||
|
export async function testAiConnection(
|
||||||
|
apiUrl: string,
|
||||||
|
apiKey: string
|
||||||
|
): Promise<TestAiConnectionResult> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const trimmedUrl = apiUrl.trim();
|
||||||
|
if (!trimmedUrl) return { error: "Enter an API URL first." };
|
||||||
|
|
||||||
|
let modelsUrl: URL;
|
||||||
|
try {
|
||||||
|
modelsUrl = buildModelsUrl(trimmedUrl);
|
||||||
|
} catch {
|
||||||
|
return { error: "That doesn't look like a valid URL." };
|
||||||
|
}
|
||||||
|
if (modelsUrl.protocol !== "http:" && modelsUrl.protocol !== "https:") {
|
||||||
|
return { error: "The API URL must be http:// or https://." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blank key field means "use whatever's already saved" -- lets an
|
||||||
|
// admin re-test without having to retype a key they can no longer see.
|
||||||
|
let effectiveKey = apiKey.trim();
|
||||||
|
if (!effectiveKey) {
|
||||||
|
const saved = await getAiCredentials();
|
||||||
|
effectiveKey = saved.apiKey ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(modelsUrl, {
|
||||||
|
headers: effectiveKey ? { Authorization: `Bearer ${effectiveKey}` } : {},
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return { error: `Couldn't reach that URL -- ${message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
return { error: `The server rejected the API key (${response.status}).` };
|
||||||
|
}
|
||||||
|
return { error: `The server responded with ${response.status} ${response.statusText}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
return { error: "Connected, but the response wasn't valid JSON." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = extractModelIds(body);
|
||||||
|
if (models.length === 0) {
|
||||||
|
return { error: "Connected, but the server didn't list any models." };
|
||||||
|
}
|
||||||
|
return { models };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAiConfig(data: {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
}): Promise<{ error?: string }> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const apiUrl = data.apiUrl.trim();
|
||||||
|
const model = data.model.trim();
|
||||||
|
if (!apiUrl) return { error: "Enter an API URL." };
|
||||||
|
if (!model) return { error: "Choose a model." };
|
||||||
|
try {
|
||||||
|
const url = new URL(apiUrl);
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
return { error: "The API URL must be http:// or https://." };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { error: "That doesn't look like a valid URL." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveAiSettings({ apiUrl, apiKey: data.apiKey.trim(), model });
|
||||||
|
revalidatePath("/admin");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,9 @@ export async function createTodo(
|
||||||
completed: todo.completed,
|
completed: todo.completed,
|
||||||
order: todo.order,
|
order: todo.order,
|
||||||
groupId: todo.groupId,
|
groupId: todo.groupId,
|
||||||
|
createdAt: todo.createdAt.toISOString(),
|
||||||
|
updatedAt: todo.updatedAt.toISOString(),
|
||||||
|
completedAt: todo.completedAt?.toISOString() ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +75,10 @@ export async function toggleTodo(todoId: string, completed: boolean): Promise<vo
|
||||||
});
|
});
|
||||||
if (!todo) throw new Error("To-do not found");
|
if (!todo) throw new Error("To-do not found");
|
||||||
|
|
||||||
await prisma.todo.update({ where: { id: todoId }, data: { completed } });
|
await prisma.todo.update({
|
||||||
|
where: { id: todoId },
|
||||||
|
data: { completed, completedAt: completed ? new Date() : null },
|
||||||
|
});
|
||||||
|
|
||||||
revalidatePath(boardPath(todo.group.category.projectId));
|
revalidatePath(boardPath(todo.group.category.projectId));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
// Fixed id of the one-and-only AiSettings row (see prisma/schema.prisma).
|
||||||
|
const SETTINGS_ID = "singleton";
|
||||||
|
|
||||||
|
export interface AiSettingsView {
|
||||||
|
apiUrl: string | null;
|
||||||
|
model: string | null;
|
||||||
|
hasApiKey: boolean;
|
||||||
|
// Last 4 characters only (e.g. "••••••••f00d") -- enough to confirm
|
||||||
|
// *which* key is configured without ever sending the real one back to
|
||||||
|
// the browser. Null when no key is set.
|
||||||
|
apiKeyMasked: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskKey(key: string): string {
|
||||||
|
return `••••••••${key.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The client-safe view of the AI settings -- never includes the real key. */
|
||||||
|
export async function getAiSettingsView(): Promise<AiSettingsView> {
|
||||||
|
const settings = await prisma.aiSettings.findUnique({ where: { id: SETTINGS_ID } });
|
||||||
|
return {
|
||||||
|
apiUrl: settings?.apiUrl ?? null,
|
||||||
|
model: settings?.model ?? null,
|
||||||
|
hasApiKey: !!settings?.apiKey,
|
||||||
|
apiKeyMasked: settings?.apiKey ? maskKey(settings.apiKey) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real, unmasked key -- for server-side use only (calling the
|
||||||
|
* provider). Never pass this to a client component or return it from a
|
||||||
|
* Server Action.
|
||||||
|
*/
|
||||||
|
export async function getAiCredentials(): Promise<{
|
||||||
|
apiUrl: string | null;
|
||||||
|
apiKey: string | null;
|
||||||
|
}> {
|
||||||
|
const settings = await prisma.aiSettings.findUnique({ where: { id: SETTINGS_ID } });
|
||||||
|
return { apiUrl: settings?.apiUrl ?? null, apiKey: settings?.apiKey ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `apiKey` omitted (or empty) means "leave the currently saved key
|
||||||
|
* alone" -- the form field is never prefilled with the real key, so
|
||||||
|
* there's nothing to resubmit when the admin isn't changing it.
|
||||||
|
*/
|
||||||
|
export async function saveAiSettings(data: {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey?: string;
|
||||||
|
model: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await prisma.aiSettings.upsert({
|
||||||
|
where: { id: SETTINGS_ID },
|
||||||
|
create: {
|
||||||
|
id: SETTINGS_ID,
|
||||||
|
apiUrl: data.apiUrl,
|
||||||
|
apiKey: data.apiKey || null,
|
||||||
|
model: data.model,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
apiUrl: data.apiUrl,
|
||||||
|
model: data.model,
|
||||||
|
...(data.apiKey ? { apiKey: data.apiKey } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,9 @@ export async function getBoard(
|
||||||
completed: todo.completed,
|
completed: todo.completed,
|
||||||
order: todo.order,
|
order: todo.order,
|
||||||
groupId: todo.groupId,
|
groupId: todo.groupId,
|
||||||
|
createdAt: todo.createdAt.toISOString(),
|
||||||
|
updatedAt: todo.updatedAt.toISOString(),
|
||||||
|
completedAt: todo.completedAt?.toISOString() ?? null,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** e.g. "Aug 12, 2026, 3:45 PM" -- absolute, not relative, so it doesn't
|
||||||
|
* need to be re-rendered on a timer just to stay accurate. */
|
||||||
|
export function formatDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Todo" ADD COLUMN "completedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- Backfill: approximate the completion time for to-dos that were already
|
||||||
|
-- marked done before this column existed. `updatedAt` is the closest
|
||||||
|
-- signal available -- for an already-completed to-do, the last write to
|
||||||
|
-- the row is often the completion toggle itself.
|
||||||
|
UPDATE "Todo" SET "completedAt" = "updatedAt" WHERE "completed" = true;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- CreateTable
|
||||||
|
-- Single-row settings table; the app always reads/writes the row at id
|
||||||
|
-- 'singleton'. No row is created here -- absence of a row just means "AI
|
||||||
|
-- generation isn't configured yet" (see lib/ai-settings.ts).
|
||||||
|
CREATE TABLE "AiSettings" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"apiUrl" TEXT,
|
||||||
|
"apiKey" TEXT,
|
||||||
|
"model" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AiSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
@ -79,6 +79,24 @@ model SiteSettings {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row config for an OpenAI-compatible AI provider (e.g. OpenWebUI)
|
||||||
|
* the admin has wired up. Same singleton pattern as SiteSettings -- see
|
||||||
|
* lib/ai-settings.ts, which is also the *only* place `apiKey` should ever
|
||||||
|
* be read in full; every other consumer gets the masked view.
|
||||||
|
*/
|
||||||
|
model AiSettings {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
apiUrl String?
|
||||||
|
// Stored as-is, not hashed -- it has to be sent to the provider
|
||||||
|
// verbatim on every request, unlike a password. Never sent back to the
|
||||||
|
// client in full.
|
||||||
|
apiKey String?
|
||||||
|
model String?
|
||||||
|
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
|
@ -134,8 +152,12 @@ model Todo {
|
||||||
title String @db.VarChar(20)
|
title String @db.VarChar(20)
|
||||||
details String? @db.Text
|
details String? @db.Text
|
||||||
completed Boolean @default(false)
|
completed Boolean @default(false)
|
||||||
|
// Set when `completed` flips to true, cleared back to null when it
|
||||||
|
// flips to false. Distinct from `updatedAt`, which bumps on *any* field
|
||||||
|
// change (a title edit, a reorder, ...), not just completion.
|
||||||
|
completedAt DateTime?
|
||||||
// Position within its Group's to-do list.
|
// Position within its Group's to-do list.
|
||||||
order Int
|
order Int
|
||||||
|
|
||||||
groupId String
|
groupId String
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ export interface TodoDTO {
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
order: number;
|
order: number;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
|
// ISO 8601 strings, not Date -- these cross the server action boundary
|
||||||
|
// and get optimistically recomputed client-side (see board-context.tsx),
|
||||||
|
// so a plain serializable string is simpler than a Date on both ends.
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupDTO {
|
export interface GroupDTO {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue