feat: add AI settings controls

This commit is contained in:
2026-04-10 15:40:29 -04:00
parent e01a7ed1ad
commit 12849b2362
16 changed files with 907 additions and 127 deletions

View File

@@ -2,6 +2,7 @@ import { headers } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { buildMultimodalMessages } from "@/lib/ai-event-messages"; import { buildMultimodalMessages } from "@/lib/ai-event-messages";
import { getAiDisabledMessage, isAdminAiEnabled } from "@/lib/ai-feature-flags";
import { extractJsonFromText } from "@/lib/json-utils"; import { extractJsonFromText } from "@/lib/json-utils";
import { openRouterClient } from "@/lib/openrouter-client"; import { openRouterClient } from "@/lib/openrouter-client";
import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types"; import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types";
@@ -86,6 +87,13 @@ const callMultimodal = async (
}; };
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isAdminAiEnabled()) {
return NextResponse.json(
{ error: getAiDisabledMessage() },
{ status: 403 },
);
}
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
}); });

View File

@@ -1,9 +1,17 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getAiDisabledMessage, isAdminAiEnabled } from "@/lib/ai-feature-flags";
import { openRouterClient } from "@/lib/openrouter-client"; import { openRouterClient } from "@/lib/openrouter-client";
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isAdminAiEnabled()) {
return NextResponse.json(
{ error: getAiDisabledMessage() },
{ status: 403 },
);
}
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
}); });

View File

@@ -204,6 +204,56 @@
} }
@layer utilities { @layer utilities {
.glass-surface {
background: linear-gradient(
180deg,
oklch(1 0 0 / 0.78),
oklch(0.985 0.003 247 / 0.92)
);
border: 1px solid oklch(0.89 0.005 247 / 0.95);
border-radius: calc(var(--radius) + 0.5rem);
box-shadow: 0 10px 30px oklch(0.3 0.01 260 / 0.08);
backdrop-filter: blur(18px) saturate(1.08);
}
.dark .glass-surface {
background: linear-gradient(
180deg,
oklch(0.23 0.015 265 / 0.72),
oklch(0.18 0.012 265 / 0.88)
);
border-color: oklch(1 0 0 / 0.09);
box-shadow: 0 18px 40px oklch(0 0 0 / 0.35);
}
.glass-panel {
background: linear-gradient(
180deg,
oklch(1 0 0 / 0.84),
oklch(0.992 0.002 247 / 0.96)
);
border: 1px solid oklch(0.89 0.005 247 / 0.95);
border-radius: calc(var(--radius) + 0.75rem);
box-shadow: 0 14px 36px oklch(0.3 0.01 260 / 0.08);
backdrop-filter: blur(20px) saturate(1.08);
}
.dark .glass-panel {
background: linear-gradient(
180deg,
oklch(0.22 0.014 265 / 0.78),
oklch(0.17 0.012 265 / 0.9)
);
border-color: oklch(1 0 0 / 0.1);
box-shadow: 0 22px 48px oklch(0 0 0 / 0.36);
}
.glass-subtle {
background: oklch(0.98 0.003 247 / 0.72);
border: 1px solid oklch(0.9 0.004 247 / 0.95);
border-radius: calc(var(--radius) + 0.5rem);
backdrop-filter: blur(14px);
}
.dark .glass-subtle {
background: oklch(0.25 0.012 265 / 0.42);
border-color: oklch(1 0 0 / 0.08);
}
/* Light: subtle card with border; Dark: glass panel */ /* Light: subtle card with border; Dark: glass panel */
.glass { .glass {
background: oklch(1 0 0 / 0.7); background: oklch(1 0 0 / 0.7);
@@ -216,16 +266,24 @@
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
} }
.glass-card { .glass-card {
background: oklch(0.995 0.001 247); background: linear-gradient(
border: 1px solid oklch(0.9 0.005 247); 180deg,
border-radius: var(--radius); oklch(1 0 0 / 0.72),
box-shadow: 0 1px 3px oklch(0.3 0.01 260 / 0.06); oklch(0.99 0.002 247 / 0.94)
);
border: 1px solid oklch(0.9 0.005 247 / 0.95);
border-radius: calc(var(--radius) + 0.25rem);
box-shadow: 0 8px 20px oklch(0.3 0.01 260 / 0.06);
backdrop-filter: blur(16px) saturate(1.06);
} }
.dark .glass-card { .dark .glass-card {
backdrop-filter: blur(16px); background: linear-gradient(
background: oklch(1 0 0 / 0.05); 180deg,
oklch(0.24 0.015 265 / 0.52),
oklch(0.18 0.012 265 / 0.72)
);
border-color: oklch(1 0 0 / 0.08); border-color: oklch(1 0 0 / 0.08);
box-shadow: none; box-shadow: 0 14px 32px oklch(0 0 0 / 0.26);
} }
.glass-strong { .glass-strong {
background: oklch(0.995 0.001 247 / 0.97); background: oklch(0.995 0.001 247 / 0.97);

View File

@@ -10,10 +10,22 @@ import { EventDialog } from "@/components/event-dialog";
import { EventsList } from "@/components/events-list"; import { EventsList } from "@/components/events-list";
import { IcsFilePicker } from "@/components/ics-file-picker"; import { IcsFilePicker } from "@/components/ics-file-picker";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import { SettingsPanel } from "@/components/settings-panel";
import SignIn from "@/components/sign-in"; import SignIn from "@/components/sign-in";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
import {
getAiDisabledMessage,
isClientAiEnabled,
} from "@/lib/ai-feature-flags";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants"; import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
import {
type EventFormValues,
getDefaultEventFormValues,
getEventFormValuesFromEvent,
} from "@/lib/event-form";
import { import {
saveEvent as addEvent, saveEvent as addEvent,
clearEvents, clearEvents,
@@ -23,12 +35,24 @@ import {
} from "@/lib/events-db"; } from "@/lib/events-db";
import { generateICS, parseICS } from "@/lib/ical"; import { generateICS, parseICS } from "@/lib/ical";
import { appendImagesDeduped } from "@/lib/multi-image"; import { appendImagesDeduped } from "@/lib/multi-image";
import {
getDefaultEventFormValues,
getEventFormValuesFromEvent,
type EventFormValues,
} from "@/lib/event-form";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
import {
APP_ACTION_BAR_CLASSES,
APP_HEADER_SURFACE_CLASSES,
APP_NAV_SURFACE_CLASSES,
APP_SECTION_SURFACE_CLASSES,
getConnectionBadgeClasses,
} from "@/lib/ui-shell-contract";
import { useUserSettings } from "@/lib/user-settings";
import { cn } from "@/lib/utils";
const APP_FRAME_CLASSES =
"mx-auto flex min-h-screen w-full max-w-3xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8";
const NAV_BUTTON_CLASSES = "flex-1 gap-2";
const getNavButtonClasses = (isActive: boolean) =>
cn(NAV_BUTTON_CLASSES, isActive ? "text-primary" : "text-muted-foreground");
const fileToBase64 = (file: File): Promise<string> => const fileToBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@@ -46,6 +70,7 @@ const validateImageFile = (file: File): string | null => {
}; };
export default function HomePage() { export default function HomePage() {
const [activeView, setActiveView] = useState<"list" | "settings">("list");
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@@ -53,9 +78,8 @@ export default function HomePage() {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [isOnline, setIsOnline] = useState(true); const [isOnline, setIsOnline] = useState(true);
const [dialogInitialValues, setDialogInitialValues] = useState<EventFormValues>( const [dialogInitialValues, setDialogInitialValues] =
getDefaultEventFormValues(), useState<EventFormValues>(getDefaultEventFormValues());
);
// AI // AI
const [aiPrompt, setAiPrompt] = useState(""); const [aiPrompt, setAiPrompt] = useState("");
@@ -70,6 +94,9 @@ export default function HomePage() {
const [imageFiles, setImageFiles] = useState<File[]>([]); const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imageBase64s, setImageBase64s] = useState<string[]>([]); const [imageBase64s, setImageBase64s] = useState<string[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]); const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const { hasLoadedSettings, settings, updateSettings } = useUserSettings();
const adminAiEnabled = isClientAiEnabled();
const canUseAi = adminAiEnabled && settings.aiEnabled;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -234,6 +261,14 @@ export default function HomePage() {
const runAiCreate = async (promptOverride?: string) => { const runAiCreate = async (promptOverride?: string) => {
const nextPrompt = promptOverride?.trim() ?? aiPrompt.trim(); const nextPrompt = promptOverride?.trim() ?? aiPrompt.trim();
if (!nextPrompt && imageBase64s.length === 0) return; if (!nextPrompt && imageBase64s.length === 0) return;
if (!canUseAi) {
toast.error(
adminAiEnabled
? "AI is turned off in Settings."
: getAiDisabledMessage(),
);
return;
}
if (promptOverride) { if (promptOverride) {
setAiPrompt(nextPrompt); setAiPrompt(nextPrompt);
@@ -260,13 +295,21 @@ export default function HomePage() {
throw new Error("Please sign in to use AI features."); throw new Error("Please sign in to use AI features.");
} }
if (res.status === 403) {
const errorBody = (await res.json()) as { error?: string };
throw new Error(errorBody.error ?? getAiDisabledMessage());
}
const data = await res.json(); const data = await res.json();
if (!Array.isArray(data) || data.length === 0) { if (!Array.isArray(data) || data.length === 0) {
throw new Error("AI did not return event data."); throw new Error("AI did not return event data.");
} }
if (data.length === 1) { if (
getAiCreateOutcome(data.length, settings.skipAiReview) ===
"review-single"
) {
populateEventForm(data[0]); populateEventForm(data[0]);
setDialogSource("ai"); setDialogSource("ai");
setAiPrompt(""); setAiPrompt("");
@@ -277,7 +320,11 @@ export default function HomePage() {
await persistAiEvents(data); await persistAiEvents(data);
setAiPrompt(""); setAiPrompt("");
setSummary(`Added ${data.length} AI-generated events.`); setSummary(
data.length === 1
? "Added 1 AI-generated event."
: `Added ${data.length} AI-generated events.`,
);
setSummaryUpdated(new Date().toLocaleString()); setSummaryUpdated(new Date().toLocaleString());
handleImagesClear(); handleImagesClear();
if (promptOverride) { if (promptOverride) {
@@ -302,6 +349,16 @@ export default function HomePage() {
// AI Summarize Events // AI Summarize Events
const handleAiSummarize = async () => { const handleAiSummarize = async () => {
if (!canUseAi) {
setSummary(
adminAiEnabled
? "AI is turned off in Settings."
: getAiDisabledMessage(),
);
setSummaryUpdated(new Date().toLocaleString());
return;
}
if (!events.length) { if (!events.length) {
setSummary("No events to summarize."); setSummary("No events to summarize.");
setSummaryUpdated(new Date().toLocaleString()); setSummaryUpdated(new Date().toLocaleString());
@@ -314,6 +371,20 @@ export default function HomePage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events }), body: JSON.stringify({ events }),
}); });
if (res.status === 401) {
setSummary("Please sign in to use AI features.");
setSummaryUpdated(new Date().toLocaleString());
return;
}
if (res.status === 403) {
const errorBody = (await res.json()) as { error?: string };
setSummary(errorBody.error ?? getAiDisabledMessage());
setSummaryUpdated(new Date().toLocaleString());
return;
}
const data = await res.json(); const data = await res.json();
if (data.summary) { if (data.summary) {
setSummary(data.summary); setSummary(data.summary);
@@ -344,8 +415,8 @@ export default function HomePage() {
onImport={handleImport} onImport={handleImport}
onImageDrop={(file) => handleImagesSelect([file])} onImageDrop={(file) => handleImagesSelect([file])}
> >
<div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8"> <div className={APP_FRAME_CLASSES}>
<header className="mb-4 flex items-center justify-between rounded-2xl border border-border/70 bg-background/80 px-4 py-3 shadow-sm backdrop-blur-sm"> <header className={APP_HEADER_SURFACE_CLASSES}>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
Offline-first iCal editor Offline-first iCal editor
@@ -354,134 +425,153 @@ export default function HomePage() {
LocalCal LocalCal
</h1> </h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/50 px-2.5 py-1 text-xs text-muted-foreground"> <Badge
variant="outline"
className={getConnectionBadgeClasses(isOnline)}
>
{isOnline ? ( {isOnline ? (
<Wifi className="h-3 w-3" /> <Wifi className="h-3 w-3" />
) : ( ) : (
<WifiOff className="h-3 w-3" /> <WifiOff className="h-3 w-3" />
)} )}
<span>{isOnline ? "Online ready" : "Offline mode"}</span> <span>{isOnline ? "Online ready" : "Offline mode"}</span>
</div> </Badge>
<SignIn /> <SignIn />
<ModeToggle /> <ModeToggle />
</div> </div>
</header> </header>
<main className="flex-1 space-y-4"> <main className="flex-1 space-y-4">
<section className="rounded-[1.5rem] border border-border/70 bg-card/95 p-4 shadow-sm sm:p-5"> {activeView === "settings" ? (
<div className="mb-4 flex items-start justify-between gap-3"> <SettingsPanel
<div className="space-y-1"> adminAiEnabled={adminAiEnabled}
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary"> className={APP_SECTION_SURFACE_CLASSES}
Create with AI hasLoadedSettings={hasLoadedSettings}
</p> onSettingsChange={updateSettings}
<h2 className="text-lg font-semibold tracking-tight"> settings={settings}
Paste details. Generate draft. Review before saving.
</h2>
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
Type or paste a natural-language description, then generate a
draft event for review in the event modal.
</p>
</div>
</div>
<AIToolbar
isAuthenticated={!!session?.user}
isPending={isPending}
aiPrompt={aiPrompt}
setAiPrompt={setAiPrompt}
aiLoading={aiLoading}
imagePreviews={imagePreviews}
onImagesSelect={handleImagesSelect}
onImageRemove={handleImageRemove}
onAiCreate={handleAiCreate}
onAiTemplateSelect={runAiCreate}
onAiSummarize={handleAiSummarize}
onSummaryDismiss={() => setSummary(null)}
summary={summary}
summaryUpdated={summaryUpdated}
events={events}
/> />
</section> ) : (
<>
<section className={APP_SECTION_SURFACE_CLASSES}>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
Create with AI
</p>
<h2 className="text-lg font-semibold tracking-tight">
Paste details. Generate draft. Review before saving.
</h2>
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
Type or paste a natural-language description, then
generate a draft event for review in the event modal.
</p>
</div>
</div>
<section className="rounded-[1.5rem] border border-border/70 bg-card/95 p-4 shadow-sm sm:p-5"> <AIToolbar
<div className="mb-4 flex items-center justify-between gap-3"> adminAiEnabled={adminAiEnabled}
<div> aiEnabled={settings.aiEnabled}
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground"> isAuthenticated={!!session?.user}
Events isPending={isPending}
</p> aiPrompt={aiPrompt}
<h2 className="text-lg font-semibold tracking-tight"> setAiPrompt={setAiPrompt}
Your local calendar timeline aiLoading={aiLoading}
</h2> imagePreviews={imagePreviews}
</div> onImagesSelect={handleImagesSelect}
<div className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground"> onImageRemove={handleImageRemove}
{events.length} item{events.length === 1 ? "" : "s"} onAiCreate={handleAiCreate}
</div> onAiTemplateSelect={runAiCreate}
</div> onAiSummarize={handleAiSummarize}
onSummaryDismiss={() => setSummary(null)}
summary={summary}
summaryUpdated={summaryUpdated}
events={events}
/>
</section>
<div className="mb-4 rounded-2xl border border-border/70 bg-muted/35 p-3"> <section className={APP_SECTION_SURFACE_CLASSES}>
<div className="flex flex-wrap items-center gap-2"> <div className="mb-4 flex items-center justify-between gap-3">
<IcsFilePicker <div>
onFileSelect={handleImport} <p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
variant="outline" Events
size="sm" </p>
className="h-9 rounded-xl gap-1.5 text-xs" <h2 className="text-lg font-semibold tracking-tight">
> Your local calendar timeline
Import </h2>
</IcsFilePicker> </div>
<div className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{events.length} item{events.length === 1 ? "" : "s"}
</div>
</div>
{events.length > 0 && ( <div className={APP_ACTION_BAR_CLASSES}>
<> <div className="flex flex-wrap items-center gap-2">
<Button <IcsFilePicker
type="button" onFileSelect={handleImport}
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleExport}
className="h-9 rounded-xl gap-1.5 text-xs" className="h-9 rounded-xl gap-1.5 text-xs"
> >
Export Import
</Button> </IcsFilePicker>
{events.length > 0 && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExport}
className="h-9 rounded-xl gap-1.5 text-xs"
>
Export
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-9 rounded-xl gap-1.5 text-xs text-muted-foreground hover:text-destructive"
>
Clear
</Button>
</>
)}
<Button <Button
type="button" type="button"
variant="ghost"
size="sm" size="sm"
onClick={handleClearAll} onClick={() => {
className="h-9 rounded-xl gap-1.5 text-xs text-muted-foreground hover:text-destructive" resetForm();
setDialogSource("manual");
setDialogOpen(true);
}}
className="ml-auto h-9 rounded-xl gap-1.5 text-xs"
> >
Clear Manual event
</Button> </Button>
</> </div>
)} </div>
<Button <EventsList
type="button" events={events}
size="sm" onEdit={handleEdit}
onClick={() => { onDelete={handleDelete}
resetForm(); />
setDialogSource("manual"); </section>
setDialogOpen(true); </>
}} )}
className="ml-auto h-9 rounded-xl gap-1.5 text-xs"
>
Manual event
</Button>
</div>
</div>
<EventsList
events={events}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</section>
</main> </main>
<nav className="fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between rounded-2xl border border-border/70 bg-background/90 px-3 py-2 shadow-lg backdrop-blur-sm sm:inset-x-6 lg:inset-x-8"> <nav className={APP_NAV_SURFACE_CLASSES}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
className="flex-1 gap-2 text-primary" className={getNavButtonClasses(activeView === "list")}
aria-pressed={activeView === "list"}
onClick={() => setActiveView("list")}
> >
<CalendarDays className="h-4 w-4" /> <CalendarDays className="h-4 w-4" />
List List
@@ -498,8 +588,9 @@ export default function HomePage() {
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
className="flex-1 gap-2 text-muted-foreground" className={getNavButtonClasses(activeView === "settings")}
disabled aria-pressed={activeView === "settings"}
onClick={() => setActiveView("settings")}
> >
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
Settings Settings

View File

@@ -71,6 +71,8 @@ function ShortcutsList({ os }: { os: Os }) {
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface AIToolbarProps { interface AIToolbarProps {
adminAiEnabled: boolean;
aiEnabled: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
isPending: boolean; isPending: boolean;
aiPrompt: string; aiPrompt: string;
@@ -94,6 +96,8 @@ interface AIToolbarProps {
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
export const AIToolbar = ({ export const AIToolbar = ({
adminAiEnabled,
aiEnabled,
isAuthenticated, isAuthenticated,
isPending, isPending,
aiPrompt, aiPrompt,
@@ -255,10 +259,28 @@ export const AIToolbar = ({
} }
const hasImages = imagePreviews.length > 0; const hasImages = imagePreviews.length > 0;
const canUseAi = adminAiEnabled && aiEnabled;
const showDisabledState = isAuthenticated && !canUseAi;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{isAuthenticated ? ( {showDisabledState ? (
<div className="flex items-center gap-3 py-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-tight text-foreground">
AI integrations are unavailable
</p>
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{adminAiEnabled
? "AI has been turned off in this browser from Settings."
: "AI integrations are currently disabled by the administrator."}
</p>
</div>
</div>
) : isAuthenticated ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/90 shadow-sm focus-within:ring-2 focus-within:ring-primary/30"> <div className="rounded-2xl border border-border/70 bg-background/90 shadow-sm focus-within:ring-2 focus-within:ring-primary/30">
<Textarea <Textarea
@@ -316,7 +338,7 @@ export const AIToolbar = ({
size="sm" size="sm"
className="h-7 max-w-full rounded-full px-2.5 text-[11px]" className="h-7 max-w-full rounded-full px-2.5 text-[11px]"
onClick={() => onAiTemplateSelect(prompt)} onClick={() => onAiTemplateSelect(prompt)}
disabled={aiLoading} disabled={aiLoading || !canUseAi}
> >
<span className="truncate">{prompt}</span> <span className="truncate">{prompt}</span>
</Button> </Button>
@@ -371,7 +393,7 @@ export const AIToolbar = ({
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<ImagePicker <ImagePicker
onFilesSelect={onImagesSelect} onFilesSelect={onImagesSelect}
disabled={aiLoading} disabled={aiLoading || !canUseAi}
multiple multiple
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -425,7 +447,7 @@ export const AIToolbar = ({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onAiSummarize} onClick={onAiSummarize}
disabled={aiLoading} disabled={aiLoading || !canUseAi}
className="h-9 gap-1.5 rounded-xl px-3 text-xs text-muted-foreground hover:text-primary" className="h-9 gap-1.5 rounded-xl px-3 text-xs text-muted-foreground hover:text-primary"
> >
<Bot className="h-3 w-3" /> <Bot className="h-3 w-3" />
@@ -437,7 +459,9 @@ export const AIToolbar = ({
size="sm" size="sm"
className="h-10 gap-1.5 rounded-xl px-4 text-xs" className="h-10 gap-1.5 rounded-xl px-4 text-xs"
onClick={onAiCreate} onClick={onAiCreate}
disabled={aiLoading || (!aiPrompt.trim() && !hasImages)} disabled={
aiLoading || !canUseAi || (!aiPrompt.trim() && !hasImages)
}
> >
{aiLoading ? ( {aiLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -456,11 +480,11 @@ export const AIToolbar = ({
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground leading-tight"> <p className="text-sm font-medium text-foreground leading-tight">
Generate event drafts with AI Sign in required to generate event drafts with AI
</p> </p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Paste natural language or a flyer, then review the filled event Sign in to turn natural language or flyers into event drafts, then
before saving. review or save them from your calendar workflow.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -27,6 +27,9 @@ interface EventCardProps {
onDelete: (eventId: string) => void; onDelete: (eventId: string) => void;
} }
export const EVENT_CARD_SURFACE_CLASSES =
"glass-card group cursor-pointer p-4 transition-[background-color,border-color,transform] duration-150 hover:-translate-y-0.5 hover:bg-accent/30 hover:border-primary/15";
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => { export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
const handleEdit = () => { const handleEdit = () => {
onEdit({ onEdit({
@@ -50,10 +53,12 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
exit={{ opacity: 0, y: -8, transition: { duration: 0.15 } }} exit={{ opacity: 0, y: -8, transition: { duration: 0.15 } }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="glass-card group cursor-pointer p-4 transition-colors duration-150 hover:bg-accent/50"> <div className={EVENT_CARD_SURFACE_CLASSES}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="min-w-0 flex-1 space-y-1.5"> <div className="min-w-0 flex-1 space-y-1.5">
<h3 className="truncate text-sm font-medium leading-snug">{event.title}</h3> <h3 className="truncate text-sm font-medium leading-snug">
{event.title}
</h3>
{event.description && ( {event.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground"> <p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">

View File

@@ -0,0 +1,179 @@
import { Sparkles, Zap } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { UserSettings } from "@/lib/user-settings";
import { cn } from "@/lib/utils";
interface SettingsPanelProps {
adminAiEnabled: boolean;
className?: string;
hasLoadedSettings: boolean;
onSettingsChange: (changes: Partial<UserSettings>) => void;
settings: UserSettings;
}
const settingRowClasses =
"rounded-2xl border border-border/70 bg-background/55 p-4 shadow-sm backdrop-blur-sm";
export function SettingsPanel({
adminAiEnabled,
className,
hasLoadedSettings,
onSettingsChange,
settings,
}: SettingsPanelProps) {
const valuePrefix = hasLoadedSettings
? "Current preference"
: "Default value";
const summaryTitle = hasLoadedSettings
? "Current values"
: "Default values while settings load";
const summaryDescription = hasLoadedSettings
? "These values come from LocalCal's saved in-browser settings and can feed future app behavior."
: "These defaults render first, then any saved values replace them once app settings finish loading.";
const panelTitle = "App preferences";
return (
<section className={className}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
Settings
</p>
<h2 className="text-lg font-semibold tracking-tight">{panelTitle}</h2>
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
These typed preferences are saved in this browser for LocalCal so
future AI workflow flags can read from one shared model.
</p>
</div>
<Badge variant="outline" className="self-start rounded-full px-3 py-1">
{hasLoadedSettings ? "Settings ready" : "Checking saved settings"}
</Badge>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,1fr)]">
<div className="grid gap-4">
<div className={settingRowClasses}>
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-full bg-primary/10 p-2 text-primary">
<Zap className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 space-y-3">
<div className="space-y-1">
<Label
htmlFor="settings-skip-ai-review"
className="cursor-pointer"
>
Prefer direct AI event creation
</Label>
<p className="text-sm leading-relaxed text-muted-foreground">
When enabled, a single AI-generated event will be created
directly instead of opening the review modal.
</p>
</div>
<Label
htmlFor="settings-skip-ai-review"
className="min-h-11 cursor-pointer justify-between rounded-xl border border-border/60 bg-muted/35 px-3 py-2"
>
<span className="text-sm font-medium text-foreground">
{settings.skipAiReview
? `${valuePrefix}: on`
: `${valuePrefix}: off`}
</span>
<Checkbox
id="settings-skip-ai-review"
checked={settings.skipAiReview}
className="size-5"
disabled={!hasLoadedSettings}
onCheckedChange={(checked) => {
onSettingsChange({ skipAiReview: checked === true });
}}
/>
</Label>
</div>
</div>
</div>
<div className={settingRowClasses}>
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-full bg-primary/10 p-2 text-primary">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 space-y-3">
<div className="space-y-1">
<Label
htmlFor="settings-ai-enabled"
className="cursor-pointer"
>
Enable AI integrations for this browser
</Label>
<p className="text-sm leading-relaxed text-muted-foreground">
Turn AI creation and summaries on or off locally.
Admin-level AI disablement still overrides this preference.
</p>
</div>
<Label
htmlFor="settings-ai-enabled"
className="min-h-11 cursor-pointer justify-between rounded-xl border border-border/60 bg-muted/35 px-3 py-2"
>
<span className="text-sm font-medium text-foreground">
{settings.aiEnabled
? `${valuePrefix}: enabled`
: `${valuePrefix}: disabled`}
</span>
<Checkbox
id="settings-ai-enabled"
checked={settings.aiEnabled}
className="size-5"
disabled={!hasLoadedSettings || !adminAiEnabled}
onCheckedChange={(checked) => {
onSettingsChange({ aiEnabled: checked === true });
}}
/>
</Label>
{!adminAiEnabled && (
<p className="text-xs leading-relaxed text-muted-foreground">
AI is disabled by the administrator, so this local
preference is read-only.
</p>
)}
</div>
</div>
</div>
</div>
<div className={cn(settingRowClasses, "space-y-3")}>
<div>
<p className="text-sm font-medium">{summaryTitle}</p>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{summaryDescription}
</p>
</div>
<dl className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<div className="rounded-xl border border-border/60 bg-muted/35 p-3">
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Direct create preference
</dt>
<dd className="mt-1 text-sm font-medium">
{settings.skipAiReview
? `${valuePrefix}: on`
: `${valuePrefix}: off`}
</dd>
</div>
<div className="rounded-xl border border-border/60 bg-muted/35 p-3">
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
AI integrations
</dt>
<dd className="mt-1 text-sm font-medium">
{settings.aiEnabled
? `${valuePrefix}: enabled`
: `${valuePrefix}: disabled`}
</dd>
</div>
</dl>
</div>
</div>
</section>
);
}

15
src/lib/ai-create-flow.ts Normal file
View File

@@ -0,0 +1,15 @@
export type AiCreateOutcome =
| "review-single"
| "create-single"
| "create-multiple";
export const getAiCreateOutcome = (
eventCount: number,
skipAiReview: boolean,
): AiCreateOutcome => {
if (eventCount > 1) {
return "create-multiple";
}
return skipAiReview ? "create-single" : "review-single";
};

View File

@@ -0,0 +1,32 @@
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
const DISABLED_VALUES = new Set(["0", "false", "no", "off"]);
const normalizeFlagValue = (value?: string) => value?.trim().toLowerCase();
export const isAiFlagEnabled = (value?: string): boolean => {
const normalizedValue = normalizeFlagValue(value);
if (!normalizedValue) {
return true;
}
if (ENABLED_VALUES.has(normalizedValue)) {
return true;
}
if (DISABLED_VALUES.has(normalizedValue)) {
return false;
}
return true;
};
export const isAdminAiEnabled = (
env: Record<string, string | undefined> = process.env,
) => isAiFlagEnabled(env.AI_ENABLED);
export const isClientAiEnabled = (
env: Record<string, string | undefined> = process.env,
) => isAiFlagEnabled(env.NEXT_PUBLIC_AI_ENABLED ?? env.AI_ENABLED);
export const getAiDisabledMessage = () =>
"AI integrations are currently disabled by the administrator.";

View File

@@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
export const APP_HEADER_SURFACE_CLASSES =
"glass-surface mb-4 flex items-center justify-between gap-3 px-4 py-3";
export const APP_SECTION_SURFACE_CLASSES = "glass-panel p-4 sm:p-5";
export const APP_ACTION_BAR_CLASSES = "glass-subtle mb-4 p-3";
export const APP_NAV_SURFACE_CLASSES =
"glass-surface fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between px-3 py-2 sm:inset-x-6 lg:inset-x-8";
const CONNECTION_BADGE_BASE_CLASSES =
"gap-1.5 border px-2.5 py-1 text-xs font-medium shadow-none";
export const getConnectionBadgeClasses = (isOnline: boolean) =>
cn(
CONNECTION_BADGE_BASE_CLASSES,
isOnline
? "border-emerald-500/35 bg-emerald-500/15 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-500/15 dark:text-emerald-300 [&>svg]:text-emerald-500"
: "border-border/70 bg-muted/55 text-muted-foreground dark:bg-muted/35 [&>svg]:text-muted-foreground",
);

118
src/lib/user-settings.ts Normal file
View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
export interface UserSettings {
aiEnabled: boolean;
skipAiReview: boolean;
}
export const USER_SETTINGS_STORAGE_KEY = "localcal:user-settings";
const DEFAULT_USER_SETTINGS: UserSettings = {
aiEnabled: true,
skipAiReview: false,
};
export const getDefaultUserSettings = (): UserSettings => ({
...DEFAULT_USER_SETTINGS,
});
type UserSettingsStorage = Pick<Storage, "getItem" | "setItem">;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const parseUserSettings = (value: unknown): UserSettings => {
if (!isRecord(value)) {
return getDefaultUserSettings();
}
return {
aiEnabled:
typeof value.aiEnabled === "boolean"
? value.aiEnabled
: DEFAULT_USER_SETTINGS.aiEnabled,
skipAiReview:
typeof value.skipAiReview === "boolean"
? value.skipAiReview
: DEFAULT_USER_SETTINGS.skipAiReview,
};
};
export const loadUserSettings = (
storage?: UserSettingsStorage,
): UserSettings => {
if (!storage) {
return getDefaultUserSettings();
}
try {
const storedValue = storage.getItem(USER_SETTINGS_STORAGE_KEY);
if (!storedValue) {
return getDefaultUserSettings();
}
return parseUserSettings(JSON.parse(storedValue));
} catch {
return getDefaultUserSettings();
}
};
export const saveUserSettings = (
settings: UserSettings,
storage?: UserSettingsStorage,
): UserSettings => {
if (!storage) {
return settings;
}
try {
storage.setItem(USER_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
} catch {
return settings;
}
return settings;
};
const getBrowserStorage = (): UserSettingsStorage | undefined => {
if (typeof window === "undefined") {
return undefined;
}
try {
return window.localStorage;
} catch {
return undefined;
}
};
export const useUserSettings = () => {
const [settings, setSettings] = useState<UserSettings>(
getDefaultUserSettings,
);
const [hasLoadedSettings, setHasLoadedSettings] = useState(false);
useEffect(() => {
setSettings(loadUserSettings(getBrowserStorage()));
setHasLoadedSettings(true);
}, []);
const updateSettings = (changes: Partial<UserSettings>) => {
setSettings((currentSettings) => {
const nextSettings = {
...currentSettings,
...changes,
};
saveUserSettings(nextSettings, getBrowserStorage());
return nextSettings;
});
};
return {
hasLoadedSettings,
settings,
updateSettings,
};
};

View File

@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test";
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
describe("AI create flow settings", () => {
test("single AI event stays in review flow when skip-review is disabled", () => {
expect(getAiCreateOutcome(1, false)).toBe("review-single");
});
test("single AI event is created directly when skip-review is enabled", () => {
expect(getAiCreateOutcome(1, true)).toBe("create-single");
});
test("multi-event AI responses always create directly regardless of skip-review", () => {
expect(getAiCreateOutcome(2, false)).toBe("create-multiple");
expect(getAiCreateOutcome(3, true)).toBe("create-multiple");
});
});

35
tests/ai-routes.test.ts Normal file
View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import {
getAiDisabledMessage,
isAdminAiEnabled,
isAiFlagEnabled,
isClientAiEnabled,
} from "@/lib/ai-feature-flags";
describe("AI feature flags", () => {
test("AI admin flag defaults to enabled when unset", () => {
expect(isAdminAiEnabled({})).toBe(true);
});
test("AI admin flag disables routes when explicitly false", () => {
expect(isAdminAiEnabled({ AI_ENABLED: "false" })).toBe(false);
expect(isAdminAiEnabled({ AI_ENABLED: "0" })).toBe(false);
});
test("AI client flag follows NEXT_PUBLIC_AI_ENABLED before server fallback", () => {
expect(
isClientAiEnabled({ NEXT_PUBLIC_AI_ENABLED: "false", AI_ENABLED: "true" }),
).toBe(false);
});
test("truthy and falsy parsing remains explicit and predictable", () => {
expect(isAiFlagEnabled("yes")).toBe(true);
expect(isAiFlagEnabled("off")).toBe(false);
expect(isAiFlagEnabled("unexpected-value")).toBe(true);
});
test("disabled message explains the admin-controlled state", () => {
expect(getAiDisabledMessage().toLowerCase()).toContain("disabled");
expect(getAiDisabledMessage().toLowerCase()).toContain("administrator");
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { getAiDisabledMessage } from "@/lib/ai-feature-flags";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -35,6 +36,9 @@ const LOCKED_AI_CTA_CLASSES =
const LOCKED_AI_TEXT_CLASSES = const LOCKED_AI_TEXT_CLASSES =
"text-sm font-medium text-foreground"; "text-sm font-medium text-foreground";
const DISABLED_AI_TEXT_CLASSES =
"text-sm leading-relaxed text-muted-foreground";
/** Data zone: neutral surface, clearly secondary to AI zone */ /** Data zone: neutral surface, clearly secondary to AI zone */
const DATA_ZONE_CLASSES = const DATA_ZONE_CLASSES =
"flex items-center gap-2 flex-wrap"; "flex items-center gap-2 flex-wrap";
@@ -86,6 +90,23 @@ describe("AI zone locked state CTA (unauthenticated)", () => {
const resolved = cn(LOCKED_AI_TEXT_CLASSES); const resolved = cn(LOCKED_AI_TEXT_CLASSES);
expect(resolved).toContain("font-medium"); expect(resolved).toContain("font-medium");
}); });
test("locked CTA copy clearly requires signing in", () => {
const copy = "Sign in required to generate event drafts with AI";
expect(copy.toLowerCase()).toContain("sign in");
expect(copy.toLowerCase()).toContain("required");
});
});
describe("AI zone disabled state", () => {
test("disabled AI body text stays muted because it is informative, not a CTA", () => {
const resolved = cn(DISABLED_AI_TEXT_CLASSES);
expect(resolved).toContain("text-muted-foreground");
});
test("admin-disabled copy explains the unavailable state", () => {
expect(getAiDisabledMessage().toLowerCase()).toContain("disabled");
});
}); });
// ─── Cycle 2: Data zone action buttons ────────────────────────────────────── // ─── Cycle 2: Data zone action buttons ──────────────────────────────────────

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import {
APP_HEADER_SURFACE_CLASSES,
APP_NAV_SURFACE_CLASSES,
APP_SECTION_SURFACE_CLASSES,
getConnectionBadgeClasses,
} from "@/lib/ui-shell-contract";
import { EVENT_CARD_SURFACE_CLASSES } from "@/components/event-card";
describe("app shell surfaces", () => {
test("header, primary sections, and bottom navigation all use shared glass utilities", () => {
expect(APP_HEADER_SURFACE_CLASSES).toMatch(/glass-surface/);
expect(APP_SECTION_SURFACE_CLASSES).toMatch(/glass-panel/);
expect(APP_NAV_SURFACE_CLASSES).toMatch(/glass-surface/);
});
test("section surface keeps responsive padding for mobile and larger breakpoints", () => {
expect(APP_SECTION_SURFACE_CLASSES).toContain("p-4");
expect(APP_SECTION_SURFACE_CLASSES).toContain("sm:p-5");
});
});
describe("event cards", () => {
test("event cards use the shared glass card treatment instead of a one-off surface", () => {
expect(EVENT_CARD_SURFACE_CLASSES).toMatch(/glass-card/);
});
});
describe("connection badge", () => {
test("online-ready badge gets a success treatment while offline stays neutral", () => {
const onlineClasses = getConnectionBadgeClasses(true);
const offlineClasses = getConnectionBadgeClasses(false);
expect(onlineClasses).toMatch(/emerald/);
expect(offlineClasses).not.toMatch(/emerald/);
expect(offlineClasses).toContain("text-muted-foreground");
});
});

109
tests/user-settings.test.ts Normal file
View File

@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test";
import {
USER_SETTINGS_STORAGE_KEY,
getDefaultUserSettings,
loadUserSettings,
saveUserSettings,
type UserSettings,
} from "@/lib/user-settings";
const createStorage = (initialState?: Record<string, string>) => {
const state = { ...initialState };
return {
getItem(key: string) {
return state[key] ?? null;
},
setItem(key: string, value: string) {
state[key] = value;
},
read(key: string) {
return state[key];
},
};
};
const createFailingStorage = () => ({
getItem() {
throw new Error("storage read failed");
},
setItem() {
throw new Error("storage write failed");
},
});
describe("user settings defaults", () => {
test("returns typed defaults for future feature flags", () => {
expect(getDefaultUserSettings()).toEqual({
aiEnabled: true,
skipAiReview: false,
});
});
test("loads defaults when no persisted settings exist yet", () => {
const storage = createStorage();
expect(loadUserSettings(storage)).toEqual(getDefaultUserSettings());
});
test("loads the saved values from shared persisted settings storage", () => {
const savedSettings: UserSettings = {
aiEnabled: false,
skipAiReview: true,
};
const storage = createStorage({
[USER_SETTINGS_STORAGE_KEY]: JSON.stringify(savedSettings),
});
expect(loadUserSettings(storage)).toEqual(savedSettings);
});
test("keeps defaults for missing or malformed saved fields", () => {
const storage = createStorage({
[USER_SETTINGS_STORAGE_KEY]: JSON.stringify({ skipAiReview: true, aiEnabled: "nope" }),
});
expect(loadUserSettings(storage)).toEqual({
aiEnabled: true,
skipAiReview: true,
});
});
test("falls back to defaults when persisted settings are not valid JSON", () => {
const storage = createStorage({
[USER_SETTINGS_STORAGE_KEY]: "{not-json",
});
expect(loadUserSettings(storage)).toEqual(getDefaultUserSettings());
});
test("falls back to defaults when storage cannot be read", () => {
expect(loadUserSettings(createFailingStorage())).toEqual(
getDefaultUserSettings(),
);
});
test("saves a complete settings snapshot under the shared persistence key", () => {
const storage = createStorage();
const nextSettings: UserSettings = {
aiEnabled: false,
skipAiReview: true,
};
expect(saveUserSettings(nextSettings, storage)).toEqual(nextSettings);
expect(storage.read(USER_SETTINGS_STORAGE_KEY)).toBe(
JSON.stringify(nextSettings),
);
});
test("returns the requested settings even when persistence fails", () => {
const nextSettings: UserSettings = {
aiEnabled: false,
skipAiReview: true,
};
expect(saveUserSettings(nextSettings, createFailingStorage())).toEqual(
nextSettings,
);
});
});