feat: redesign

Signed-off-by: Dmytro Stanchiev <git@dmytros.dev>
This commit is contained in:
2026-04-21 20:23:15 -04:00
parent 420a971ff7
commit 915e0b7cf8
21 changed files with 1401 additions and 537 deletions

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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",
},
},

View File

@@ -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}

View File

@@ -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}
/>
);

View File

@@ -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}

View File

@@ -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}

View File

@@ -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);

View File

@@ -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",
);