feat: journal dashboard widgets, agent polish, and edit-mode live previews
CI / checks (push) Failing after 2m7s
CI / build (push) Successful in 4m36s

Journal dashboard widgets and quick-add; rich-text quick-add dialogs.

Dashboard draft sync for live edit previews; assistant bubble + API tools.

Journal UX: stress slider, mood grid, query cap fix.
This commit is contained in:
ginnoir
2026-07-04 22:03:45 -05:00
parent 4a924a4107
commit a09747c314
76 changed files with 4594 additions and 611 deletions
+50 -1
View File
@@ -1,10 +1,13 @@
import { z } from "zod";
import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth";
import { getAssistantEnabled } from "@/lib/assistant-preference";
import { isLlmConfigured } from "@/lib/llm";
import { encodeSseEvent } from "@/modules/agent/server/progress";
import { runAgentChat } from "@/modules/agent/server/run";
const chatInput = z.object({
stream: z.boolean().optional(),
messages: z
.array(
z.object({
@@ -18,10 +21,15 @@ const chatInput = z.object({
export async function POST(request: Request) {
const auth = await resolveApiAuth(request);
if (!auth) {
if (!auth?.userId) {
return apiError("Unauthorized", 401);
}
const assistantEnabled = await getAssistantEnabled(auth.userId);
if (!assistantEnabled) {
return apiError("Assistant not enabled", 403);
}
let body: unknown;
try {
body = await request.json();
@@ -34,6 +42,47 @@ export async function POST(request: Request) {
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
}
if (parsed.data.stream) {
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: Parameters<typeof encodeSseEvent>[0]) => {
controller.enqueue(encoder.encode(encodeSseEvent(event)));
};
try {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
onProgress: send,
});
send({
type: "done",
message: {
role: "assistant",
content: result.message.content,
},
toolCalls: result.toolCalls,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Agent request failed";
send({ type: "error", message });
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
try {
const result = await runAgentChat({
messages: parsed.data.messages,
@@ -0,0 +1,13 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { scheduleOnCalendarForScope } from "@/modules/garden/server/actions";
import { scheduleOnCalendarInput } from "@/modules/garden/server/schemas";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = scheduleOnCalendarInput.parse({ ...(body as object), scheduleId: id });
await scheduleOnCalendarForScope(scope, parsed);
return apiJson({ ok: true }, 201);
});
}
@@ -0,0 +1,26 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
deleteCareScheduleForScope,
toggleCareScheduleForScope,
} from "@/modules/garden/server/actions";
import { careScheduleToggleInput } from "@/modules/garden/server/schemas";
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = careScheduleToggleInput.parse({ ...(body as object), id });
await toggleCareScheduleForScope(scope, parsed);
return apiJson({ ok: true });
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteCareScheduleForScope(scope, { id });
return apiJson({ ok: true });
});
}
@@ -0,0 +1,9 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { pushOverdueToTaskListForScope } from "@/modules/garden/server/actions";
export async function POST(request: Request) {
return withApiHandler(request, async (scope) => {
const result = await pushOverdueToTaskListForScope(scope);
return apiJson(result, 201);
});
}
@@ -0,0 +1,37 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { logCareForScope } from "@/modules/garden/server/actions";
import { careLogInput } from "@/modules/garden/server/schemas";
import { getCareLogsForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const url = new URL(req.url);
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? z.coerce.number().int().min(1).max(100).parse(limitRaw) : 20;
const logs = await getCareLogsForScope(scope.householdId, id, limit);
return apiJson(logs);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = careLogInput.parse({ ...(body as object), plantId: id });
const log = await logCareForScope(scope, parsed);
return apiJson(
{
id: log.id,
careType: log.careType,
notes: log.notes,
performedAt: log.performedAt.toISOString(),
performedBy: log.performedBy,
},
201,
);
});
}
@@ -0,0 +1,35 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { upsertCareScheduleForScope } from "@/modules/garden/server/actions";
import { careScheduleInput } from "@/modules/garden/server/schemas";
import { getCareSchedulesForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const schedules = await getCareSchedulesForScope(scope.householdId, id);
return apiJson(schedules);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = careScheduleInput.parse({ ...(body as object), plantId: id });
const schedule = await upsertCareScheduleForScope(scope, parsed);
return apiJson(
{
id: schedule.id,
careType: schedule.careType,
intervalDays: schedule.intervalDays,
lastPerformedAt: schedule.lastPerformedAt?.toISOString() ?? null,
nextDueAt: schedule.nextDueAt?.toISOString() ?? null,
enabled: schedule.enabled,
},
201,
);
});
}
+5 -2
View File
@@ -4,8 +4,11 @@ import { journalEntryInput } from "@/modules/journal/server/schemas";
import { listJournalEntriesForScope } from "@/modules/journal/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope) => {
const entries = await listJournalEntriesForScope(scope);
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const limitParam = url.searchParams.get("limit");
const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 20, 1), 100) : undefined;
const entries = await listJournalEntriesForScope(scope, limit ? { limit } : undefined);
return apiJson(entries);
});
}
+14
View File
@@ -0,0 +1,14 @@
import { readFile } from "fs/promises";
import path from "path";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) {
return withApiHandler(request, async () => {
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
const spec = await readFile(specPath, "utf8");
return new Response(spec, {
status: 200,
headers: { "Content-Type": "application/yaml; charset=utf-8" },
});
});
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { revokeShareLinkForScope } from "@/modules/_core/share-api";
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await revokeShareLinkForScope(scope, id);
return apiJson({ ok: true });
});
}
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
createShareLinkForScope,
listShareLinksForScope,
listShareableEntityTypes,
} from "@/modules/_core/share-api";
const createShareLinkInput = z.object({
entityType: z.string().trim().min(1),
entityId: z.string().uuid(),
expiresAt: z.string().datetime().nullable().optional(),
capabilities: z
.object({
read: z.boolean().optional(),
write: z.boolean().optional(),
})
.optional(),
});
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const entityType = url.searchParams.get("entityType") ?? undefined;
const entityId = url.searchParams.get("entityId") ?? undefined;
if (url.searchParams.get("entityTypes") === "true") {
return apiJson(listShareableEntityTypes());
}
const links = await listShareLinksForScope(scope, { entityType, entityId });
return apiJson(links);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = createShareLinkInput.parse(body);
const expiresAt = parsed.expiresAt ? new Date(parsed.expiresAt) : null;
const result = await createShareLinkForScope(scope, parsed.entityType, parsed.entityId, {
expiresAt,
capabilities: parsed.capabilities,
});
return apiJson(
{
url: result.url,
expiresAt: result.expiresAt?.toISOString() ?? null,
},
201,
);
});
}
-6
View File
@@ -1,6 +0,0 @@
import { AssistantChat } from "@/modules/agent/components/assistant-chat";
import { isLlmConfigured } from "@/lib/llm";
export default function AssistantPage() {
return <AssistantChat configured={isLlmConfigured()} />;
}
+6 -2
View File
@@ -2,7 +2,8 @@ import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout, widgetContentKey } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
import { getWidget, getWidgetMetas } from "@/modules/_core";
import { getDashboardBySlug } from "@/app/d/actions";
import { DashboardEditor } from "@/components/dashboard-editor";
@@ -49,7 +50,9 @@ export default async function DashboardPage({
const dashboard = await getDashboardBySlug(slug);
if (!dashboard) notFound();
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
const storedLayout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
const draftLayout = isEditing ? await readEditorDraftLayout(dashboard.id) : null;
const layout = normalizeDashboardLayout(draftLayout ?? storedLayout);
const widgetMetas = getWidgetMetas();
if (isEditing) {
@@ -67,6 +70,7 @@ export default async function DashboardPage({
return (
<DashboardEditor
key={`${dashboard.id}:${JSON.stringify(layout.widgets)}`}
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
layout={layout}
widgetMetas={widgetMetas}
+32
View File
@@ -10,6 +10,7 @@ import { getWidget } from "@/modules/_core";
import { dashboards } from "@/modules/_core/schema";
import { type DashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { clearEditorDraftLayout, writeEditorDraftLayout } from "@/lib/dashboard-editor-draft";
export type DashboardMeta = {
id: string;
@@ -156,9 +157,39 @@ export async function saveDashboardLayout(id: string, layout: DashboardLayout):
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
revalidatePath("/");
}
export async function syncEditorDraftLayout(id: string, layout: DashboardLayout): Promise<void> {
const { user } = await getCurrentSession();
const [row] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
.limit(1);
if (!row) throw new Error("Dashboard not found");
for (const placement of layout.widgets) {
const widget = getWidget(placement.widgetId);
if (!widget) continue;
widget.configSchema.parse(placement.config);
}
await writeEditorDraftLayout(id, layout);
}
export async function discardEditorDraftLayout(id: string): Promise<void> {
const { user } = await getCurrentSession();
const [row] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
.limit(1);
if (!row) throw new Error("Dashboard not found");
await clearEditorDraftLayout(id);
}
export async function resetDashboardLayout(id: string): Promise<void> {
const { user } = await getCurrentSession();
const layout = computeDefaultLayout();
@@ -166,6 +197,7 @@ export async function resetDashboardLayout(id: string): Promise<void> {
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
revalidatePath("/");
}
+139
View File
@@ -818,6 +818,86 @@
bottom: 18px;
}
/* ── Assistant chat bubble (opt-in per user) ─────────────────────── */
.assistant-bubble {
position: fixed;
right: 18px;
bottom: 24px;
z-index: 70;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
pointer-events: none;
}
:where(html[data-nav="bottom"]) .assistant-bubble {
bottom: calc(62px + max(env(safe-area-inset-bottom, 0px), 8px) + 12px);
}
:where(html[data-nav="fab"]) .assistant-bubble {
bottom: calc(18px + 52px + 12px);
}
.assistant-bubble > * {
pointer-events: auto;
}
.assistant-bubble-panel {
display: flex;
flex-direction: column;
width: min(360px, calc(100vw - 24px));
height: min(480px, calc(100vh - 140px));
border-radius: var(--r-md);
border: 0.5px solid var(--hair);
background: color-mix(in oklab, var(--card) 94%, transparent);
backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 12px 40px rgba(31, 27, 22, 0.16),
0 2px 8px rgba(31, 27, 22, 0.08);
padding: 12px;
overflow: hidden;
}
.assistant-bubble-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
flex-shrink: 0;
}
.assistant-bubble-close {
appearance: none;
border: 0;
background: transparent;
color: var(--ink-mute);
border-radius: 8px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.assistant-bubble-close:hover {
background: var(--shade);
color: var(--ink);
}
.assistant-bubble-trigger {
width: 52px;
height: 52px;
border-radius: 999px;
border: 0;
background: var(--ink);
color: var(--paper);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow:
0 4px 14px rgba(31, 27, 22, 0.22),
0 1px 2px rgba(31, 27, 22, 0.18);
}
.assistant-bubble-trigger:hover {
background: var(--ink-2);
}
/* ── Buttons (used inside .topbar etc; <Button> in shadcn comes from
button.tsx and uses these tokens via the theme bridge) ───── */
.btn {
@@ -1430,6 +1510,65 @@ select {
animation: emojiFloat 1.8s ease-out both;
}
@keyframes journal-paper-out {
to {
opacity: 0;
transform: translateX(-28px) rotate(-1.2deg);
}
}
@keyframes journal-paper-in {
from {
opacity: 0;
transform: translateX(32px) rotate(0.8deg);
}
to {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
}
.journal-entries-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
.journal-entries-stack[data-phase="exit"] .journal-entry-row {
animation: journal-paper-out 0.2s ease-in forwards;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
opacity: 0;
animation: journal-paper-in 0.26s ease-out forwards;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(1) {
animation-delay: 0ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(2) {
animation-delay: 45ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(3) {
animation-delay: 90ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(4) {
animation-delay: 135ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(5) {
animation-delay: 180ms;
}
@media (prefers-reduced-motion: reduce) {
.journal-entries-stack[data-phase="exit"] .journal-entry-row,
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
animation: none;
opacity: 1;
transform: none;
}
}
/* ── Calendar min-height CSS variable ───────────────────────────── */
/* Used by calendar-shell so the grid fills the viewport correctly */
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
+7 -3
View File
@@ -1,9 +1,13 @@
import { recordedDayKey } from "@/modules/journal/day-key";
import { Suspense } from "react";
import { JournalIndex } from "@/modules/journal/components/journal-index";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalPage() {
const entries = await listJournalEntries();
const entryDays = [...new Set(entries.map((entry) => recordedDayKey(entry.recordedAt)))];
return <JournalIndex entries={entries} entryDays={entryDays} />;
return (
<Suspense>
<JournalIndex entries={entries} />
</Suspense>
);
}
+8
View File
@@ -17,6 +17,8 @@ import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster";
import { AssistantBubble } from "@/modules/agent/components/assistant-bubble";
import { isLlmConfigured } from "@/lib/llm";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -99,6 +101,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
let userDashboards: DashboardMeta[] = [];
let signedIn = false;
let assistantEnabled = false;
const session = await auth();
if (session?.user?.id) {
@@ -110,6 +113,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
themeFontPair: users.themeFontPair,
themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle,
assistantEnabled: users.assistantEnabled,
})
.from(users)
.where(eq(users.id, session.user.id))
@@ -120,6 +124,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
fontPair = row.themeFontPair as FontPair;
density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle;
assistantEnabled = row.assistantEnabled;
}
userDashboards = await db
.select({
@@ -172,6 +177,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<CommandPalette />
<InstallPrompt />
<PwaRegister />
{signedIn && assistantEnabled && session?.user?.id ? (
<AssistantBubble configured={isLlmConfigured()} userId={session.user.id} />
) : null}
<AppToaster position="bottom-right" />
</QuickAddProvider>
</body>
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { signIn } from "@/lib/auth";
import { createDevSession } from "@/lib/dev-login";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
export async function signInWithSso() {
await signIn("authentik", { redirectTo: "/" });
}
export async function devLogin() {
if (!isDevLoginEnabled()) {
throw new Error("Dev login is not enabled");
}
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}
+6 -32
View File
@@ -1,10 +1,7 @@
import { signIn } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { createDevSession } from "@/lib/dev-login";
import { BrandMark } from "@/components/brand-mark";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { devLogin, signInWithSso } from "./actions";
export default function LoginPage() {
const devLoginEnabled = isDevLoginEnabled();
@@ -23,41 +20,18 @@ export default function LoginPage() {
</div>
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
<form
action={async () => {
"use server";
await signIn("authentik", { redirectTo: "/" });
}}
>
<form action={signInWithSso}>
<Button type="submit" size="lg" className="w-full">
Sign in with SSO
</Button>
</form>
{devLoginEnabled && (
<form
action={async () => {
"use server";
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
// trustHost, and proxy headers. Set both so the session is found
// regardless — this is dev-only code, correctness > elegance.
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}}
className="mt-3"
>
{devLoginEnabled ? (
<form action={devLogin} className="mt-3">
<Button type="submit" variant="outline" size="lg" className="w-full">
Dev login
</Button>
</form>
)}
) : null}
</div>
</main>
);
+14
View File
@@ -0,0 +1,14 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session";
export async function setAssistantEnabled(enabled: boolean): Promise<void> {
const { user } = await getCurrentSession();
await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id));
revalidatePath("/settings");
revalidatePath("/", "layout");
}
+48 -23
View File
@@ -13,11 +13,23 @@ import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions";
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
import { ApiTokenSettings } from "@/components/api-token-settings";
import { AssistantOptIn } from "@/components/assistant-opt-in";
import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries";
import Link from "next/link";
import { NavIcon } from "@/components/nav-icon";
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus, KeyRound } from "lucide-react";
import {
Mail,
Globe,
History,
Sun,
Bell,
Pencil,
Lock,
Plus,
KeyRound,
MessageCircle,
} from "lucide-react";
const VALID_SECTIONS = new Set<SectionId>([
"household",
@@ -289,31 +301,44 @@ function AppearanceSection({
themeDashLayout: string;
themeCalView: string;
themeNavStyle: string;
assistantEnabled: boolean;
};
}) {
return (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<Sun className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<ThemePicker
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
initialMode={user.themeMode as "light" | "dark" | "system"}
initialFontPair={
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
}
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
initialCalView={user.themeCalView as "month" | "week" | "day"}
initialNavStyle={
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
}
signedIn
/>
</CardContent>
</Card>
<>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<Sun className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<ThemePicker
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
initialMode={user.themeMode as "light" | "dark" | "system"}
initialFontPair={
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
}
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
initialCalView={user.themeCalView as "month" | "week" | "day"}
initialNavStyle={
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
}
signedIn
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Assistant</CardTitle>
<MessageCircle className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<AssistantOptIn enabled={user.assistantEnabled} />
</CardContent>
</Card>
</>
);
}