@@ -4,98 +4,121 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(0.985 0.002 247);
|
||||
--foreground: oklch(0.145 0.015 260);
|
||||
--card: oklch(0.99 0.002 247);
|
||||
--card-foreground: oklch(0.145 0.015 260);
|
||||
--popover: oklch(0.99 0.002 247);
|
||||
--popover-foreground: oklch(0.145 0.015 260);
|
||||
--primary: oklch(0.55 0.22 265);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.96 0.004 247);
|
||||
--secondary-foreground: oklch(0.3 0.015 260);
|
||||
--muted: oklch(0.96 0.004 247);
|
||||
--muted-foreground: oklch(0.5 0.015 260);
|
||||
--accent: oklch(0.94 0.025 270);
|
||||
--accent-foreground: oklch(0.35 0.03 260);
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.915 0.004 247);
|
||||
--input: oklch(0.915 0.004 247);
|
||||
--ring: oklch(0.55 0.22 265);
|
||||
--chart-1: oklch(0.55 0.22 265);
|
||||
--chart-2: oklch(0.65 0.2 250);
|
||||
--chart-3: oklch(0.6 0.18 280);
|
||||
--chart-4: oklch(0.5 0.2 300);
|
||||
--chart-5: oklch(0.45 0.18 320);
|
||||
--sidebar: oklch(0.97 0.003 247);
|
||||
--sidebar-foreground: oklch(0.145 0.015 260);
|
||||
--sidebar-primary: oklch(0.55 0.22 265);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.94 0.025 270);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.03 260);
|
||||
--sidebar-border: oklch(0.915 0.004 247);
|
||||
--sidebar-ring: oklch(0.55 0.22 265);
|
||||
--radius: 0.75rem;
|
||||
--shadow-2xs: 0 1px 2px oklch(0.3 0.01 260 / 0.06);
|
||||
--shadow-xs: 0 1px 3px oklch(0.3 0.01 260 / 0.08);
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #171717;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #171717;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #fafafa;
|
||||
--secondary-foreground: #171717;
|
||||
--muted: #fafafa;
|
||||
--muted-foreground: #666666;
|
||||
--accent: #f5f5f5;
|
||||
--accent-foreground: #171717;
|
||||
--destructive: #ff5b4f;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #ebebeb;
|
||||
--input: #ebebeb;
|
||||
--ring: hsla(212, 100%, 48%, 1);
|
||||
--chart-1: #171717;
|
||||
--chart-2: #0a72ef;
|
||||
--chart-3: #de1d8d;
|
||||
--chart-4: #666666;
|
||||
--chart-5: #ebebeb;
|
||||
--sidebar: #fafafa;
|
||||
--sidebar-foreground: #171717;
|
||||
--sidebar-primary: #171717;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #f5f5f5;
|
||||
--sidebar-accent-foreground: #171717;
|
||||
--sidebar-border: #ebebeb;
|
||||
--sidebar-ring: hsla(212, 100%, 48%, 1);
|
||||
--radius: 0.625rem;
|
||||
--shadow-2xs: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
--shadow-xs: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
--shadow-sm:
|
||||
0 2px 8px oklch(0.3 0.01 260 / 0.08), 0 1px 2px oklch(0.3 0.01 260 / 0.06);
|
||||
0 0 0 1px rgba(0, 0, 0, 0.08), 0 2px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow:
|
||||
0 4px 16px oklch(0.3 0.01 260 / 0.1), 0 1px 3px oklch(0.3 0.01 260 / 0.06);
|
||||
0 0 0 1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 2px rgba(0, 0, 0, 0.04),
|
||||
0 8px 8px -8px rgba(0, 0, 0, 0.04),
|
||||
0 0 0 1px #fafafa;
|
||||
--shadow-md:
|
||||
0 8px 24px oklch(0.3 0.01 260 / 0.12), 0 2px 6px oklch(0.3 0.01 260 / 0.06);
|
||||
0 0 0 1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 2px rgba(0, 0, 0, 0.04),
|
||||
0 8px 8px -8px rgba(0, 0, 0, 0.04),
|
||||
0 0 0 1px #fafafa;
|
||||
--shadow-lg:
|
||||
0 16px 48px oklch(0.3 0.01 260 / 0.14),
|
||||
0 4px 12px oklch(0.3 0.01 260 / 0.06);
|
||||
--shadow-xl: 0 24px 64px oklch(0.3 0.01 260 / 0.18);
|
||||
--shadow-2xl: 0 32px 80px oklch(0.3 0.01 260 / 0.25);
|
||||
0 0 0 1px rgba(0, 0, 0, 0.08),
|
||||
0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
--shadow-xl:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.08),
|
||||
0 16px 40px rgba(0, 0, 0, 0.12);
|
||||
--shadow-2xl:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.08),
|
||||
0 24px 56px rgba(0, 0, 0, 0.16);
|
||||
--tracking-normal: -0.01em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.13 0.01 265);
|
||||
--foreground: oklch(0.93 0.01 260);
|
||||
--card: oklch(0.17 0.012 265);
|
||||
--card-foreground: oklch(0.93 0.01 260);
|
||||
--popover: oklch(0.17 0.012 265);
|
||||
--popover-foreground: oklch(0.93 0.01 260);
|
||||
--primary: oklch(0.7 0.18 265);
|
||||
--primary-foreground: oklch(0.13 0.01 265);
|
||||
--secondary: oklch(0.22 0.012 265);
|
||||
--secondary-foreground: oklch(0.88 0.008 260);
|
||||
--muted: oklch(0.22 0.012 265);
|
||||
--muted-foreground: oklch(0.65 0.015 260);
|
||||
--accent: oklch(0.25 0.02 270);
|
||||
--accent-foreground: oklch(0.88 0.008 260);
|
||||
--destructive: oklch(0.6 0.22 25);
|
||||
--destructive-foreground: oklch(0.98 0.002 260);
|
||||
--border: oklch(0.25 0.012 265);
|
||||
--input: oklch(0.25 0.012 265);
|
||||
--ring: oklch(0.7 0.18 265);
|
||||
--chart-1: oklch(0.7 0.18 265);
|
||||
--chart-2: oklch(0.65 0.2 250);
|
||||
--chart-3: oklch(0.6 0.22 280);
|
||||
--chart-4: oklch(0.55 0.18 300);
|
||||
--chart-5: oklch(0.5 0.15 320);
|
||||
--sidebar: oklch(0.15 0.012 265);
|
||||
--sidebar-foreground: oklch(0.93 0.01 260);
|
||||
--sidebar-primary: oklch(0.7 0.18 265);
|
||||
--sidebar-primary-foreground: oklch(0.13 0.01 265);
|
||||
--sidebar-accent: oklch(0.25 0.02 270);
|
||||
--sidebar-accent-foreground: oklch(0.88 0.008 260);
|
||||
--sidebar-border: oklch(0.25 0.012 265);
|
||||
--sidebar-ring: oklch(0.7 0.18 265);
|
||||
--radius: 0.75rem;
|
||||
--shadow-2xs: 0 1px 2px oklch(0 0 0 / 0.2);
|
||||
--shadow-xs: 0 1px 3px oklch(0 0 0 / 0.25);
|
||||
--shadow-sm: 0 2px 8px oklch(0 0 0 / 0.25), 0 1px 2px oklch(0 0 0 / 0.15);
|
||||
--shadow: 0 4px 16px oklch(0 0 0 / 0.3), 0 1px 3px oklch(0 0 0 / 0.15);
|
||||
--shadow-md: 0 8px 24px oklch(0 0 0 / 0.35), 0 2px 6px oklch(0 0 0 / 0.15);
|
||||
--shadow-lg: 0 16px 48px oklch(0 0 0 / 0.4), 0 4px 12px oklch(0 0 0 / 0.15);
|
||||
--shadow-xl: 0 24px 64px oklch(0 0 0 / 0.5);
|
||||
--shadow-2xl: 0 32px 80px oklch(0 0 0 / 0.6);
|
||||
--background: #111111;
|
||||
--foreground: #f5f5f5;
|
||||
--card: #171717;
|
||||
--card-foreground: #f5f5f5;
|
||||
--popover: #171717;
|
||||
--popover-foreground: #f5f5f5;
|
||||
--primary: #f5f5f5;
|
||||
--primary-foreground: #171717;
|
||||
--secondary: #1f1f1f;
|
||||
--secondary-foreground: #f5f5f5;
|
||||
--muted: #1a1a1a;
|
||||
--muted-foreground: #a1a1a1;
|
||||
--accent: #1f1f1f;
|
||||
--accent-foreground: #f5f5f5;
|
||||
--destructive: #ff5b4f;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--input: rgba(255, 255, 255, 0.12);
|
||||
--ring: hsla(212, 100%, 48%, 1);
|
||||
--chart-1: #f5f5f5;
|
||||
--chart-2: #0a72ef;
|
||||
--chart-3: #de1d8d;
|
||||
--chart-4: #a1a1a1;
|
||||
--chart-5: rgba(255, 255, 255, 0.1);
|
||||
--sidebar: #171717;
|
||||
--sidebar-foreground: #f5f5f5;
|
||||
--sidebar-primary: #f5f5f5;
|
||||
--sidebar-primary-foreground: #171717;
|
||||
--sidebar-accent: #1f1f1f;
|
||||
--sidebar-accent-foreground: #f5f5f5;
|
||||
--sidebar-border: rgba(255, 255, 255, 0.1);
|
||||
--sidebar-ring: hsla(212, 100%, 48%, 1);
|
||||
--radius: 0.625rem;
|
||||
--shadow-2xs: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
--shadow-xs: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
--shadow-sm:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08), 0 2px 2px rgba(0, 0, 0, 0.24);
|
||||
--shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
0 2px 2px rgba(0, 0, 0, 0.24),
|
||||
0 8px 8px -8px rgba(0, 0, 0, 0.32);
|
||||
--shadow-md:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
0 2px 2px rgba(0, 0, 0, 0.24),
|
||||
0 8px 8px -8px rgba(0, 0, 0, 0.32);
|
||||
--shadow-lg:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
0 12px 28px rgba(0, 0, 0, 0.32);
|
||||
--shadow-xl:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
0 20px 44px rgba(0, 0, 0, 0.4);
|
||||
--shadow-2xl:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
0 28px 64px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
||||
237
src/app/page.tsx
237
src/app/page.tsx
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarDays, ListTodo, Settings, Wifi, WifiOff } from "lucide-react";
|
||||
import {
|
||||
CalendarDays,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -14,6 +20,13 @@ 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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
|
||||
import {
|
||||
getAiDisabledMessage,
|
||||
@@ -47,7 +60,7 @@ 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";
|
||||
"mx-auto flex min-h-screen w-full max-w-6xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8";
|
||||
|
||||
const NAV_BUTTON_CLASSES = "flex-1 gap-2";
|
||||
|
||||
@@ -408,6 +421,13 @@ export default function HomePage() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openManualEventDialog = () => {
|
||||
resetForm();
|
||||
setDialogSource("manual");
|
||||
setActiveView("list");
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContainer
|
||||
isDragOver={isDragOver}
|
||||
@@ -417,12 +437,12 @@ export default function HomePage() {
|
||||
>
|
||||
<div className={APP_FRAME_CLASSES}>
|
||||
<header className={APP_HEADER_SURFACE_CLASSES}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Offline-first iCal editor
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<p className="font-mono text-[11px] uppercase text-muted-foreground">
|
||||
Local Calendar
|
||||
</p>
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight">
|
||||
LocalCal
|
||||
<h1 className="truncate text-[28px] font-semibold tracking-[-0.06em]">
|
||||
Event timeline
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
@@ -439,6 +459,39 @@ export default function HomePage() {
|
||||
</Badge>
|
||||
<SignIn />
|
||||
<ModeToggle />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={events.length === 0}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
More
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={openManualEventDialog}>
|
||||
Manual create
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setActiveView("settings")}>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={handleClearAll}
|
||||
disabled={events.length === 0}
|
||||
>
|
||||
Clear all events
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -452,116 +505,91 @@ export default function HomePage() {
|
||||
settings={settings}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
<section className="grid items-start gap-4 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]">
|
||||
<div className="order-1 lg:order-none space-y-4">
|
||||
<section className={APP_SECTION_SURFACE_CLASSES}>
|
||||
<div className="mb-4 space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
AI capture
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
Paste details. Generate draft. Review before saving.
|
||||
<h2 className="text-xl font-semibold tracking-[-0.04em]">
|
||||
Capture from text or files.
|
||||
</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 className="text-sm leading-6 text-muted-foreground">
|
||||
Start with a prompt, screenshots, or both. Review happens in the
|
||||
timeline rather than a separate flow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AIToolbar
|
||||
adminAiEnabled={adminAiEnabled}
|
||||
aiEnabled={settings.aiEnabled}
|
||||
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>
|
||||
<AIToolbar
|
||||
adminAiEnabled={adminAiEnabled}
|
||||
aiEnabled={settings.aiEnabled}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<section className={APP_SECTION_SURFACE_CLASSES}>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Events
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
Your local calendar timeline
|
||||
</h2>
|
||||
<div className="order-2 lg:order-none space-y-4">
|
||||
<section className={APP_SECTION_SURFACE_CLASSES}>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Event timeline
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold tracking-[-0.04em]">
|
||||
Review the calendar by scanning the stream.
|
||||
</h2>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<div className={APP_ACTION_BAR_CLASSES}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<IcsFilePicker
|
||||
onFileSelect={handleImport}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 rounded-xl gap-1.5 text-xs"
|
||||
>
|
||||
Import
|
||||
</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>
|
||||
|
||||
<div className={APP_ACTION_BAR_CLASSES}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<IcsFilePicker
|
||||
onFileSelect={handleImport}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Import
|
||||
</IcsFilePicker>
|
||||
{events.length > 0 && (
|
||||
<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"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setDialogSource("manual");
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
className="ml-auto h-9 rounded-xl gap-1.5 text-xs"
|
||||
>
|
||||
Manual event
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EventsList
|
||||
events={events}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
<EventsList
|
||||
events={events}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -574,16 +602,7 @@ export default function HomePage() {
|
||||
onClick={() => setActiveView("list")}
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
List
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex-1 gap-2 text-muted-foreground"
|
||||
disabled
|
||||
>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
Tasks
|
||||
Timeline
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -281,183 +281,126 @@ export const AIToolbar = ({
|
||||
</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
|
||||
id="ai-event-prompt"
|
||||
className="wrap-anywhere field-sizing-content min-h-40 w-full resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm shadow-none placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="Type or paste event details…"
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// ⌘↵ — generate
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
(aiPrompt.trim() || hasImages)
|
||||
) {
|
||||
e.preventDefault();
|
||||
onAiCreate();
|
||||
}
|
||||
// ⌘⇧A — attach image
|
||||
if (
|
||||
e.key === "A" &&
|
||||
e.shiftKey &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!aiLoading
|
||||
) {
|
||||
e.preventDefault();
|
||||
imageTriggerRef.current?.open();
|
||||
}
|
||||
// Esc — clear prompt (only when not composing a native action)
|
||||
if (e.key === "Escape" && aiPrompt) {
|
||||
e.preventDefault();
|
||||
setAiPrompt("");
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const images = extractAllImagesFromClipboard(
|
||||
e.clipboardData ?? null,
|
||||
);
|
||||
if (images.length > 0) {
|
||||
e.preventDefault();
|
||||
onImagesSelect(images);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="sticky bottom-0 z-10 border-t border-border/60 bg-background/95 px-3 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Try:
|
||||
</span>
|
||||
{examplePrompts.map((prompt) => (
|
||||
<Button
|
||||
key={prompt}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 max-w-full rounded-full px-2.5 text-[11px]"
|
||||
onClick={() => onAiTemplateSelect(prompt)}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
>
|
||||
<span className="truncate">{prompt}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{hasImages && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex gap-2 overflow-x-auto py-1 ml-3">
|
||||
{imagePreviews.map((preview, index) => (
|
||||
<motion.div
|
||||
key={preview}
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.85 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="relative inline-block shrink-0"
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-[10px] bg-card shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.04),0_0_0_1px_#fafafa] focus-within:ring-[3px] focus-within:ring-ring/20">
|
||||
<Textarea
|
||||
id="ai-event-prompt"
|
||||
className="wrap-anywhere field-sizing-content min-h-48 w-full resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm shadow-none placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
placeholder="Type or paste event details..."
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
(aiPrompt.trim() || hasImages)
|
||||
) {
|
||||
e.preventDefault();
|
||||
onAiCreate();
|
||||
}
|
||||
if (
|
||||
e.key === "A" &&
|
||||
e.shiftKey &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!aiLoading
|
||||
) {
|
||||
e.preventDefault();
|
||||
imageTriggerRef.current?.open();
|
||||
}
|
||||
if (e.key === "Escape" && aiPrompt) {
|
||||
e.preventDefault();
|
||||
setAiPrompt("");
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const images = extractAllImagesFromClipboard(
|
||||
e.clipboardData ?? null,
|
||||
);
|
||||
if (images.length > 0) {
|
||||
e.preventDefault();
|
||||
onImagesSelect(images);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Try:
|
||||
</span>
|
||||
{examplePrompts.map((prompt) => (
|
||||
<Button
|
||||
key={prompt}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 max-w-full rounded-full px-2.5 text-[11px]"
|
||||
onClick={() => onAiTemplateSelect(prompt)}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
>
|
||||
<Image
|
||||
src={preview}
|
||||
alt={`Attached image ${index + 1}`}
|
||||
className="h-16 w-16 rounded-md object-cover ring-1 ring-primary/30"
|
||||
width={64}
|
||||
height={64}
|
||||
unoptimized
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
|
||||
onClick={() => onImageRemove(index)}
|
||||
aria-label={`Remove image ${index + 1}`}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
<span className="truncate">{prompt}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<ImagePicker
|
||||
onFilesSelect={onImagesSelect}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
multiple
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
triggerRef={imageTriggerRef}
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
Attach image
|
||||
</ImagePicker>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HoverCard
|
||||
openDelay={300}
|
||||
closeDelay={100}
|
||||
open={isPopoverOpen ? false : undefined}
|
||||
>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<HoverCardTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden h-8 w-8 text-muted-foreground/70 hover:text-foreground md:inline-flex"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</HoverCardTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HoverCard
|
||||
openDelay={300}
|
||||
closeDelay={100}
|
||||
open={isPopoverOpen ? false : undefined}
|
||||
>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<HoverCardTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden h-8 w-8 text-muted-foreground/70 hover:text-foreground md:inline-flex"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</HoverCardTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="w-52 p-3"
|
||||
>
|
||||
<ShortcutsList os={os} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="w-52 p-3"
|
||||
>
|
||||
<ShortcutsList os={os} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<HoverCardContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="w-52 p-3"
|
||||
>
|
||||
<ShortcutsList os={os} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
{events.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAiSummarize}
|
||||
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" />
|
||||
Summarize
|
||||
</Button>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAiSummarize}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
className="h-9 gap-1.5 px-3 text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
Summarize
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 gap-1.5 rounded-xl px-4 text-xs"
|
||||
className="h-10 gap-1.5 px-4 text-xs"
|
||||
onClick={onAiCreate}
|
||||
disabled={
|
||||
aiLoading || !canUseAi || (!aiPrompt.trim() && !hasImages)
|
||||
@@ -468,10 +411,84 @@ export const AIToolbar = ({
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiLoading ? "Generating…" : "Generate draft"}
|
||||
{aiLoading ? "Generating..." : "Generate event"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] bg-card p-3 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Attachments
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
Add screenshots, flyers, or pasted images alongside the prompt.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
|
||||
{imagePreviews.length} file{imagePreviews.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ImagePicker
|
||||
onFilesSelect={onImagesSelect}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
multiple
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-full justify-center gap-2"
|
||||
triggerRef={imageTriggerRef}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Attach images
|
||||
</ImagePicker>
|
||||
|
||||
<AnimatePresence>
|
||||
{hasImages ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="mt-3 grid gap-2 sm:grid-cols-2"
|
||||
>
|
||||
{imagePreviews.map((preview, index) => (
|
||||
<motion.div
|
||||
key={preview}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="relative overflow-hidden rounded-[8px] bg-muted"
|
||||
>
|
||||
<Image
|
||||
src={preview}
|
||||
alt={`Attached image ${index + 1}`}
|
||||
className="h-32 w-full object-cover"
|
||||
width={256}
|
||||
height={160}
|
||||
unoptimized
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-7 w-7 rounded-full"
|
||||
onClick={() => onImageRemove(index)}
|
||||
aria-label={`Remove image ${index + 1}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-[8px] border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Drop or paste images here to pair them with the prompt.
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { formatEventRangeLabel } from "@/lib/event-date-format";
|
||||
import { getEventValidationIssues } from "@/lib/event-form";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
interface EventCardProps {
|
||||
@@ -28,9 +29,11 @@ interface EventCardProps {
|
||||
}
|
||||
|
||||
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";
|
||||
"group cursor-pointer rounded-[10px] bg-card p-4 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.04),0_0_0_1px_#fafafa] transition-[background-color,transform,box-shadow] duration-150 hover:-translate-y-0.5 hover:bg-accent/20";
|
||||
|
||||
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
const validationIssues = getEventValidationIssues(event);
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit({
|
||||
id: event.id,
|
||||
@@ -66,7 +69,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
{formatEventRangeLabel(event)}
|
||||
@@ -83,7 +86,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto gap-1 p-0 text-xs text-primary/70 hover:text-primary"
|
||||
className="h-auto gap-1 p-0 text-xs"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
@@ -102,6 +105,12 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
{event.recurrenceRule && (
|
||||
<RRuleDisplay rrule={event.recurrenceRule} start={event.start} />
|
||||
)}
|
||||
|
||||
{validationIssues.length > 0 && (
|
||||
<div className="rounded-[8px] bg-[#fff4f2] px-3 py-2 text-xs text-[#b42318] shadow-[inset_0_0_0_1px_rgba(180,35,24,0.14)] dark:bg-[#2a1715] dark:text-[#ff8a80]">
|
||||
Warning: {validationIssues[0]}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -147,13 +147,15 @@ export const EventDialog = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="glass-strong max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">{titleText}</DialogTitle>
|
||||
<DialogContent className="max-w-2xl rounded-[10px] bg-card p-0 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_12px_40px_rgba(0,0,0,0.12)]">
|
||||
<DialogHeader className="border-b border-foreground/10 px-6 py-5">
|
||||
<DialogTitle className="text-[28px] tracking-[-0.06em]">
|
||||
{titleText}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{descriptionText}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-3" onSubmit={onSubmit}>
|
||||
<form className="grid gap-6 px-6 py-5" onSubmit={onSubmit}>
|
||||
{isAiDraft && (
|
||||
<div className="rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs leading-relaxed text-primary">
|
||||
This draft was generated from natural language. Double-check
|
||||
@@ -161,149 +163,165 @@ export const EventDialog = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-title">Title</Label>
|
||||
<Input
|
||||
id="event-title"
|
||||
placeholder="Event title"
|
||||
className="font-medium"
|
||||
{...register("title")}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-xs text-destructive">{errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-description">Description / notes</Label>
|
||||
<Textarea
|
||||
id="event-description"
|
||||
className="field-sizing-content min-h-[60px] max-h-40 resize-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Add a description..."
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<section className="grid gap-3">
|
||||
<p className="font-mono text-[11px] uppercase text-muted-foreground">
|
||||
Event details
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-location">Location</Label>
|
||||
<Label htmlFor="event-title">Title</Label>
|
||||
<Input
|
||||
id="event-title"
|
||||
placeholder="Event title"
|
||||
className="font-medium"
|
||||
{...register("title")}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-xs text-destructive">{errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-description">Description / notes</Label>
|
||||
<Textarea
|
||||
id="event-description"
|
||||
className="field-sizing-content min-h-[60px] max-h-40 resize-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Add a description..."
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-location">Location</Label>
|
||||
<Controller
|
||||
name="location"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<LocationAutocomplete
|
||||
id="event-location"
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-url">URL</Label>
|
||||
<Input id="event-url" placeholder="URL" {...register("url")} />
|
||||
{errors.url && (
|
||||
<p className="text-xs text-destructive">{errors.url.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<p className="font-mono text-[11px] uppercase text-muted-foreground">
|
||||
Schedule
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<Controller
|
||||
name="location"
|
||||
name="allDay"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<LocationAutocomplete
|
||||
id="event-location"
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
<Checkbox
|
||||
id="event-all-day"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(checked === true)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="event-all-day"
|
||||
className="cursor-pointer text-sm font-normal"
|
||||
>
|
||||
All day
|
||||
</Label>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-url">URL</Label>
|
||||
<Input id="event-url" placeholder="URL" {...register("url")} />
|
||||
{errors.url && (
|
||||
<p className="text-xs text-destructive">{errors.url.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="recurrenceRule"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RecurrencePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
start={start}
|
||||
<div className="space-y-2">
|
||||
<Controller
|
||||
name="start"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
allDay={allDay}
|
||||
placeholder="Start date"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.recurrenceRule && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.recurrenceRule.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<Controller
|
||||
name="allDay"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="event-all-day"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(checked === true)
|
||||
}
|
||||
{!allDay && (
|
||||
<Controller
|
||||
name="end"
|
||||
control={control}
|
||||
render={() => (
|
||||
<div className="flex gap-1 pl-0.5">
|
||||
{DURATIONS.map(({ label, minutes }) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!start}
|
||||
onClick={() =>
|
||||
handleApplyDuration(minutes, allDay, start)
|
||||
}
|
||||
className="px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="event-all-day"
|
||||
className="cursor-pointer text-sm font-normal"
|
||||
>
|
||||
All day
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Controller
|
||||
name="start"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
allDay={allDay}
|
||||
placeholder="Start date"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!allDay && (
|
||||
<Controller
|
||||
name="end"
|
||||
control={control}
|
||||
render={() => (
|
||||
<div className="flex gap-1 pl-0.5">
|
||||
{DURATIONS.map(({ label, minutes }) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!start}
|
||||
onClick={() =>
|
||||
handleApplyDuration(minutes, allDay, start)
|
||||
}
|
||||
className="px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
allDay={allDay}
|
||||
placeholder="End date"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{errors.start && (
|
||||
<p className="text-xs text-destructive">{errors.start.message}</p>
|
||||
)}
|
||||
{errors.end && (
|
||||
<p className="text-xs text-destructive">{errors.end.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<p className="font-mono text-[11px] uppercase text-muted-foreground">
|
||||
Recurrence
|
||||
</p>
|
||||
<Controller
|
||||
name="end"
|
||||
name="recurrenceRule"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
<RecurrencePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
allDay={allDay}
|
||||
placeholder="End date"
|
||||
start={start}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.start && (
|
||||
<p className="text-xs text-destructive">{errors.start.message}</p>
|
||||
{errors.recurrenceRule && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.recurrenceRule.message}
|
||||
</p>
|
||||
)}
|
||||
{errors.end && (
|
||||
<p className="text-xs text-destructive">{errors.end.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
|
||||
@@ -17,13 +17,13 @@ export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-muted/20 px-6 py-16 text-center"
|
||||
className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-border/80 bg-muted/30 px-6 py-16 text-center shadow-[inset_0_0_0_1px_rgba(255,255,255,0.35)]"
|
||||
>
|
||||
<Calendar1Icon className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground">No events yet</h3>
|
||||
<p className="mt-1 max-w-sm text-xs leading-relaxed text-muted-foreground/70">
|
||||
Generate a draft from natural language or add an event manually to
|
||||
start building your local calendar timeline.
|
||||
Capture something with AI or open manual create from More to start
|
||||
building your event timeline.
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ interface SettingsPanelProps {
|
||||
}
|
||||
|
||||
const settingRowClasses =
|
||||
"rounded-2xl border border-border/70 bg-background/55 p-4 shadow-sm backdrop-blur-sm";
|
||||
"rounded-[10px] bg-card p-4 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
|
||||
|
||||
export function SettingsPanel({
|
||||
adminAiEnabled,
|
||||
|
||||
@@ -5,18 +5,18 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium [&>svg]:size-3 [&>svg]:pointer-events-none focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 transition-[color,box-shadow,background-color] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
"bg-secondary text-secondary-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.08)] [a&]:hover:bg-accent",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
"bg-[#ebf5ff] text-[#0068d6] shadow-[0_0_0_1px_rgba(0,0,0,0.08)] [a&]:hover:opacity-90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive/12 text-destructive shadow-[0_0_0_1px_rgba(255,91,79,0.2)] [a&]:hover:bg-destructive/18 focus-visible:ring-destructive/25",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
"bg-background text-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.08)] [a&]:hover:bg-accent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -5,26 +5,24 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"active:scale-[.95] inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium duration-100 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive [&::-moz-focus-inner]:border-0 [&::-moz-focus-inner]:p-0",
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[6px] text-sm font-medium transition-[background-color,color,box-shadow,opacity] duration-150 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&::-moz-focus-inner]:border-0 [&::-moz-focus-inner]:p-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:opacity-92",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-destructive-foreground hover:opacity-92 focus-visible:ring-destructive/25 dark:focus-visible:ring-destructive/35",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"bg-background text-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.08)] hover:bg-accent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"bg-secondary text-secondary-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.05)] hover:bg-accent",
|
||||
ghost: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
link: "text-[#0072f5] underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-[10px] py-6 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.04),0_0_0_1px_#fafafa]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -38,7 +38,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-background/80 backdrop-blur-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -60,7 +60,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-5 rounded-[10px] p-6 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.04),0_0_0_1px_#fafafa] duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -69,7 +69,7 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
className="focus:ring-ring/30 data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-[6px] p-1 opacity-70 transition-[background-color,opacity] hover:opacity-100 focus:ring-[3px] focus:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
@@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
className={cn("flex flex-col gap-2 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -110,7 +110,7 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
className={cn("text-[24px] leading-none font-semibold tracking-[-0.04em]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -123,7 +123,7 @@ function DialogDescription({
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn("text-muted-foreground text-sm leading-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[10px] p-1.5 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -74,7 +74,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-[6px] px-2.5 py-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-[6px] py-2 pr-2.5 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-[6px] py-2 pr-2.5 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -155,7 +155,7 @@ function DropdownMenuLabel({
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
"px-2.5 py-1.5 text-xs font-medium tracking-[0.08em] text-muted-foreground uppercase data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-[6px] px-2.5 py-2 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[10px] p-1.5 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-10 w-full min-w-0 rounded-[8px] bg-background px-3 py-2 text-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)] transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:ring-[3px] focus-visible:ring-ring/25",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -83,5 +83,30 @@ export const getEventFormValuesFromEvent = (
|
||||
recurrenceRule: event?.recurrenceRule || undefined,
|
||||
});
|
||||
|
||||
export const getEventValidationIssues = (
|
||||
event: Pick<CalendarEvent, "start" | "end" | "url">,
|
||||
) => {
|
||||
const issues: string[] = [];
|
||||
|
||||
if (event.end) {
|
||||
const startDate = new Date(event.start);
|
||||
const endDate = new Date(event.end);
|
||||
|
||||
if (
|
||||
!Number.isNaN(startDate.getTime()) &&
|
||||
!Number.isNaN(endDate.getTime()) &&
|
||||
endDate.getTime() < startDate.getTime()
|
||||
) {
|
||||
issues.push("end time is before start time");
|
||||
}
|
||||
}
|
||||
|
||||
if (event.url && !URL.canParse(event.url)) {
|
||||
issues.push("link is invalid");
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
export const validateEventFormValues = (values: EventFormValues) =>
|
||||
eventFormSchema.safeParse(values);
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
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";
|
||||
"mb-6 flex min-h-14 items-center justify-between gap-3 border-b border-foreground/10 bg-background/95 px-4 py-3 sm:px-6";
|
||||
|
||||
export const APP_SECTION_SURFACE_CLASSES = "glass-panel p-4 sm:p-5";
|
||||
export const APP_SECTION_SURFACE_CLASSES =
|
||||
"rounded-[10px] bg-card px-4 py-4 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.04),0_0_0_1px_#fafafa] sm:px-5";
|
||||
|
||||
export const APP_ACTION_BAR_CLASSES = "glass-subtle mb-4 p-3";
|
||||
export const APP_ACTION_BAR_CLASSES =
|
||||
"rounded-[10px] bg-card px-3 py-3 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
|
||||
|
||||
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";
|
||||
"fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between rounded-[10px] bg-background/95 px-3 py-2 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_8px_24px_rgba(0,0,0,0.08)] sm:inset-x-6 lg:hidden";
|
||||
|
||||
const CONNECTION_BADGE_BASE_CLASSES =
|
||||
"gap-1.5 border px-2.5 py-1 text-xs font-medium shadow-none";
|
||||
"gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
|
||||
|
||||
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",
|
||||
? "bg-[#ebf5ff] text-[#0068d6]"
|
||||
: "bg-muted text-muted-foreground",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user