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

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

View File

@@ -27,6 +27,9 @@ interface EventCardProps {
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) => {
const handleEdit = () => {
onEdit({
@@ -50,10 +53,12 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
exit={{ opacity: 0, y: -8, transition: { duration: 0.15 } }}
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="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 && (
<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>
);
}