feat: add AI settings controls
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user