diff --git a/src/app/api/ai-event/route.ts b/src/app/api/ai-event/route.ts index a394a39..b0ab58b 100644 --- a/src/app/api/ai-event/route.ts +++ b/src/app/api/ai-event/route.ts @@ -2,6 +2,7 @@ import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { auth } from "@/auth"; import { buildMultimodalMessages } from "@/lib/ai-event-messages"; +import { getAiDisabledMessage, isAdminAiEnabled } from "@/lib/ai-feature-flags"; import { extractJsonFromText } from "@/lib/json-utils"; import { openRouterClient } from "@/lib/openrouter-client"; import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types"; @@ -86,6 +87,13 @@ const callMultimodal = async ( }; export async function POST(request: Request) { + if (!isAdminAiEnabled()) { + return NextResponse.json( + { error: getAiDisabledMessage() }, + { status: 403 }, + ); + } + const session = await auth.api.getSession({ headers: await headers(), }); diff --git a/src/app/api/ai-summary/route.ts b/src/app/api/ai-summary/route.ts index 5385487..0ba6ea9 100644 --- a/src/app/api/ai-summary/route.ts +++ b/src/app/api/ai-summary/route.ts @@ -1,9 +1,17 @@ import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { auth } from "@/auth"; +import { getAiDisabledMessage, isAdminAiEnabled } from "@/lib/ai-feature-flags"; import { openRouterClient } from "@/lib/openrouter-client"; export async function POST(request: Request) { + if (!isAdminAiEnabled()) { + return NextResponse.json( + { error: getAiDisabledMessage() }, + { status: 403 }, + ); + } + const session = await auth.api.getSession({ headers: await headers(), }); diff --git a/src/app/globals.css b/src/app/globals.css index ad6c2b3..cf723cf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -204,6 +204,56 @@ } @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 */ .glass { background: oklch(1 0 0 / 0.7); @@ -216,16 +266,24 @@ backdrop-filter: blur(12px); } .glass-card { - background: oklch(0.995 0.001 247); - border: 1px solid oklch(0.9 0.005 247); - border-radius: var(--radius); - box-shadow: 0 1px 3px oklch(0.3 0.01 260 / 0.06); + background: linear-gradient( + 180deg, + oklch(1 0 0 / 0.72), + 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 { - backdrop-filter: blur(16px); - background: oklch(1 0 0 / 0.05); + background: linear-gradient( + 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); - box-shadow: none; + box-shadow: 0 14px 32px oklch(0 0 0 / 0.26); } .glass-strong { background: oklch(0.995 0.001 247 / 0.97); diff --git a/src/app/page.tsx b/src/app/page.tsx index 6d25298..d995a2e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,10 +10,22 @@ import { EventDialog } from "@/components/event-dialog"; import { EventsList } from "@/components/events-list"; import { IcsFilePicker } from "@/components/ics-file-picker"; import { ModeToggle } from "@/components/mode-toggle"; +import { SettingsPanel } from "@/components/settings-panel"; import SignIn from "@/components/sign-in"; +import { Badge } from "@/components/ui/badge"; 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 { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants"; +import { + type EventFormValues, + getDefaultEventFormValues, + getEventFormValuesFromEvent, +} from "@/lib/event-form"; import { saveEvent as addEvent, clearEvents, @@ -23,12 +35,24 @@ import { } from "@/lib/events-db"; import { generateICS, parseICS } from "@/lib/ical"; import { appendImagesDeduped } from "@/lib/multi-image"; -import { - getDefaultEventFormValues, - getEventFormValuesFromEvent, - type EventFormValues, -} from "@/lib/event-form"; 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 => new Promise((resolve, reject) => { @@ -46,6 +70,7 @@ const validateImageFile = (file: File): string | null => { }; export default function HomePage() { + const [activeView, setActiveView] = useState<"list" | "settings">("list"); const [events, setEvents] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); @@ -53,9 +78,8 @@ export default function HomePage() { const [isDragOver, setIsDragOver] = useState(false); const [isOnline, setIsOnline] = useState(true); - const [dialogInitialValues, setDialogInitialValues] = useState( - getDefaultEventFormValues(), - ); + const [dialogInitialValues, setDialogInitialValues] = + useState(getDefaultEventFormValues()); // AI const [aiPrompt, setAiPrompt] = useState(""); @@ -70,6 +94,9 @@ export default function HomePage() { const [imageFiles, setImageFiles] = useState([]); const [imageBase64s, setImageBase64s] = useState([]); const [imagePreviews, setImagePreviews] = useState([]); + const { hasLoadedSettings, settings, updateSettings } = useUserSettings(); + const adminAiEnabled = isClientAiEnabled(); + const canUseAi = adminAiEnabled && settings.aiEnabled; useEffect(() => { (async () => { @@ -234,6 +261,14 @@ export default function HomePage() { const runAiCreate = async (promptOverride?: string) => { const nextPrompt = promptOverride?.trim() ?? aiPrompt.trim(); if (!nextPrompt && imageBase64s.length === 0) return; + if (!canUseAi) { + toast.error( + adminAiEnabled + ? "AI is turned off in Settings." + : getAiDisabledMessage(), + ); + return; + } if (promptOverride) { setAiPrompt(nextPrompt); @@ -260,13 +295,21 @@ export default function HomePage() { 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(); if (!Array.isArray(data) || data.length === 0) { throw new Error("AI did not return event data."); } - if (data.length === 1) { + if ( + getAiCreateOutcome(data.length, settings.skipAiReview) === + "review-single" + ) { populateEventForm(data[0]); setDialogSource("ai"); setAiPrompt(""); @@ -277,7 +320,11 @@ export default function HomePage() { await persistAiEvents(data); 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()); handleImagesClear(); if (promptOverride) { @@ -302,6 +349,16 @@ export default function HomePage() { // AI Summarize Events const handleAiSummarize = async () => { + if (!canUseAi) { + setSummary( + adminAiEnabled + ? "AI is turned off in Settings." + : getAiDisabledMessage(), + ); + setSummaryUpdated(new Date().toLocaleString()); + return; + } + if (!events.length) { setSummary("No events to summarize."); setSummaryUpdated(new Date().toLocaleString()); @@ -314,6 +371,20 @@ export default function HomePage() { headers: { "Content-Type": "application/json" }, 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(); if (data.summary) { setSummary(data.summary); @@ -344,8 +415,8 @@ export default function HomePage() { onImport={handleImport} onImageDrop={(file) => handleImagesSelect([file])} > -
-
+
+

Offline-first iCal editor @@ -354,134 +425,153 @@ export default function HomePage() { LocalCal

-
-
+
+ {isOnline ? ( ) : ( )} {isOnline ? "Online ready" : "Offline mode"} -
+
-
-
-
-

- Create with AI -

-

- Paste details. Generate draft. Review before saving. -

-

- Type or paste a natural-language description, then generate a - draft event for review in the event modal. -

-
-
- - setSummary(null)} - summary={summary} - summaryUpdated={summaryUpdated} - events={events} + {activeView === "settings" ? ( + -
+ ) : ( + <> +
+
+
+

+ Create with AI +

+

+ Paste details. Generate draft. Review before saving. +

+

+ Type or paste a natural-language description, then + generate a draft event for review in the event modal. +

+
+
-
-
-
-

- Events -

-

- Your local calendar timeline -

-
-
- {events.length} item{events.length === 1 ? "" : "s"} -
-
+ setSummary(null)} + summary={summary} + summaryUpdated={summaryUpdated} + events={events} + /> +
-
-
- - Import - +
+
+
+

+ Events +

+

+ Your local calendar timeline +

+
+
+ {events.length} item{events.length === 1 ? "" : "s"} +
+
- {events.length > 0 && ( - <> - + Import + + + {events.length > 0 && ( + <> + + + + + )} - - )} +
+
- -
-
- - - + + + + )} -