feat: add AI settings controls
This commit is contained in:
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
311
src/app/page.tsx
311
src/app/page.tsx
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
179
src/components/settings-panel.tsx
Normal file
179
src/components/settings-panel.tsx
Normal 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
15
src/lib/ai-create-flow.ts
Normal 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";
|
||||||
|
};
|
||||||
32
src/lib/ai-feature-flags.ts
Normal file
32
src/lib/ai-feature-flags.ts
Normal 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.";
|
||||||
22
src/lib/ui-shell-contract.ts
Normal file
22
src/lib/ui-shell-contract.ts
Normal 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
118
src/lib/user-settings.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
17
tests/ai-create-settings.test.ts
Normal file
17
tests/ai-create-settings.test.ts
Normal 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
35
tests/ai-routes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 ──────────────────────────────────────
|
||||||
|
|||||||
38
tests/ui-shell-contract.test.ts
Normal file
38
tests/ui-shell-contract.test.ts
Normal 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
109
tests/user-settings.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user