style(components): standardize main component file formatting

This commit is contained in:
2026-04-07 08:10:05 -04:00
parent 954e73c007
commit fd5716f39e
12 changed files with 1002 additions and 846 deletions

View File

@@ -1,17 +1,17 @@
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Textarea } from '@/components/ui/textarea' import { Textarea } from "@/components/ui/textarea";
import { Card } from '@/components/ui/card' import { Card } from "@/components/ui/card";
interface AIToolbarProps { interface AIToolbarProps {
isAuthenticated: boolean isAuthenticated: boolean;
isPending: boolean isPending: boolean;
aiPrompt: string aiPrompt: string;
setAiPrompt: (prompt: string) => void setAiPrompt: (prompt: string) => void;
aiLoading: boolean aiLoading: boolean;
onAiCreate: () => void onAiCreate: () => void;
onAiSummarize: () => void onAiSummarize: () => void;
summary: string | null summary: string | null;
summaryUpdated: string | null summaryUpdated: string | null;
} }
export const AIToolbar = ({ export const AIToolbar = ({
@@ -23,28 +23,30 @@ export const AIToolbar = ({
onAiCreate, onAiCreate,
onAiSummarize, onAiSummarize,
summary, summary,
summaryUpdated summaryUpdated,
}: AIToolbarProps) => { }: AIToolbarProps) => {
return ( return (
<> <>
{isPending ? ( {isPending ? (
<div className='mb-4 p-4 text-center animate-pulse bg-muted'>Loading...</div> <div className="mb-4 p-4 text-center animate-pulse bg-muted">
Loading...
</div>
) : ( ) : (
<div> <div>
{isAuthenticated ? ( {isAuthenticated ? (
<div className="flex flex-col sm:flex-row gap-4 mb-4 items-start"> <div className="flex flex-col sm:flex-row gap-4 mb-4 items-start">
<div className='w-full'> <div className="w-full">
<Textarea <Textarea
className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto sm:overflow-y-visible px-3 py-2 scroll-p-8 placeholder:italic" className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto sm:overflow-y-visible px-3 py-2 scroll-p-8 placeholder:italic"
style={{ clipPath: "inset(0 round 1rem)" }} style={{ clipPath: "inset(0 round 1rem)" }}
placeholder='Describe event for AI to create' placeholder="Describe event for AI to create"
value={aiPrompt} value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)} onChange={(e) => setAiPrompt(e.target.value)}
/> />
</div> </div>
<div className='flex flex-row gap-2 pt-1'> <div className="flex flex-row gap-2 pt-1">
<Button onClick={onAiCreate} disabled={aiLoading}> <Button onClick={onAiCreate} disabled={aiLoading}>
{aiLoading ? 'Thinking...' : 'AI Create'} {aiLoading ? "Thinking..." : "AI Create"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -61,20 +63,22 @@ export const AIToolbar = ({
{/* Summary Panel */} {/* Summary Panel */}
{summary && ( {summary && (
<Card className="p-4 mb-4"> <Card className="p-4 mb-4">
<div className="text-sm mb-1"> <div className="text-sm mb-1">Summary updated {summaryUpdated}</div>
Summary updated {summaryUpdated}
</div>
<div>{summary}</div> <div>{summary}</div>
</Card> </Card>
)} )}
{/* AI Actions Toolbar */} {/* AI Actions Toolbar */}
<p className='text-muted-foreground text-sm pb-2 pl-1'>AI actions</p> <p className="text-muted-foreground text-sm pb-2 pl-1">AI actions</p>
<div className="gap-2 mb-4"> <div className="gap-2 mb-4">
<Button variant="secondary" onClick={onAiSummarize} disabled={aiLoading}> <Button
{aiLoading ? 'Summarizing...' : 'AI Summarize'} variant="secondary"
onClick={onAiSummarize}
disabled={aiLoading}
>
{aiLoading ? "Summarizing..." : "AI Summarize"}
</Button> </Button>
</div> </div>
</> </>
) );
} };

View File

@@ -1,41 +1,41 @@
import { ReactNode } from 'react' import { ReactNode } from "react";
import { toast } from 'sonner' import { toast } from "sonner";
interface DragDropContainerProps { interface DragDropContainerProps {
children: ReactNode children: ReactNode;
isDragOver: boolean isDragOver: boolean;
setIsDragOver: (isDragOver: boolean) => void setIsDragOver: (isDragOver: boolean) => void;
onImport: (file: File) => void onImport: (file: File) => void;
} }
export const DragDropContainer = ({ export const DragDropContainer = ({
children, children,
isDragOver, isDragOver,
setIsDragOver, setIsDragOver,
onImport onImport,
}: DragDropContainerProps) => { }: DragDropContainerProps) => {
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
setIsDragOver(true) setIsDragOver(true);
} };
const handleDragLeave = (e: React.DragEvent) => { const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
setIsDragOver(false) setIsDragOver(false);
} };
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
setIsDragOver(false) setIsDragOver(false);
if (e.dataTransfer.files?.length) { if (e.dataTransfer.files?.length) {
const file = e.dataTransfer.files[0] const file = e.dataTransfer.files[0];
if (file.name.endsWith('.ics')) { if (file.name.endsWith(".ics")) {
onImport(file) onImport(file);
} else { } else {
toast.warning('Please drop an .ics file') toast.warning("Please drop an .ics file");
}
} }
} }
};
return ( return (
<div <div
@@ -43,13 +43,13 @@ export const DragDropContainer = ({
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
className={`p-4 min-h-[80vh] flex flex-col rounded border-2 border-dashed transition ${ className={`p-4 min-h-[80vh] flex flex-col rounded border-2 border-dashed transition ${
isDragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-700' isDragOver ? "border-blue-500 bg-blue-50" : "border-gray-700"
}`} }`}
> >
{children} {children}
<div className='mt-auto w-full pb-4 text-gray-400'> <div className="mt-auto w-full pb-4 text-gray-400">
<div className='max-w-fit m-auto'>Drag & Drop *.ics here</div> <div className="max-w-fit m-auto">Drag & Drop *.ics here</div>
</div> </div>
</div> </div>
) );
} };

View File

@@ -1,13 +1,13 @@
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { IcsFilePicker } from '@/components/ics-file-picker' import { IcsFilePicker } from "@/components/ics-file-picker";
import type { CalendarEvent } from '@/lib/types' import type { CalendarEvent } from "@/lib/types";
interface EventActionsToolbarProps { interface EventActionsToolbarProps {
events: CalendarEvent[] events: CalendarEvent[];
onAddEvent: () => void onAddEvent: () => void;
onImport: (file: File) => void onImport: (file: File) => void;
onExport: () => void onExport: () => void;
onClearAll: () => void onClearAll: () => void;
} }
export const EventActionsToolbar = ({ export const EventActionsToolbar = ({
@@ -15,22 +15,28 @@ export const EventActionsToolbar = ({
onAddEvent, onAddEvent,
onImport, onImport,
onExport, onExport,
onClearAll onClearAll,
}: EventActionsToolbarProps) => { }: EventActionsToolbarProps) => {
return ( return (
<> <>
{/* Control Toolbar */} {/* Control Toolbar */}
<p className='text-muted-foreground text-sm pb-2 pl-1'>Event Actions</p> <p className="text-muted-foreground text-sm pb-2 pl-1">Event Actions</p>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
<Button onClick={onAddEvent}>Add Event</Button> <Button onClick={onAddEvent}>Add Event</Button>
<IcsFilePicker onFileSelect={onImport} variant='secondary'>Import .ics</IcsFilePicker> <IcsFilePicker onFileSelect={onImport} variant="secondary">
Import .ics
</IcsFilePicker>
{events.length > 0 && ( {events.length > 0 && (
<> <>
<Button variant="secondary" onClick={onExport}>Export .ics</Button> <Button variant="secondary" onClick={onExport}>
<Button variant="destructive" onClick={onClearAll}>Clear All</Button> Export .ics
</Button>
<Button variant="destructive" onClick={onClearAll}>
Clear All
</Button>
</> </>
)} )}
</div> </div>
</> </>
) );
} };

View File

@@ -1,35 +1,40 @@
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from '@/components/ui/card' import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { LucideMapPin, Clock, MoreHorizontal } from 'lucide-react' import { LucideMapPin, Clock, MoreHorizontal } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu' import {
import { RRuleDisplay } from '@/components/rrule-display' DropdownMenu,
import type { CalendarEvent } from '@/lib/types' DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { RRuleDisplay } from "@/components/rrule-display";
import type { CalendarEvent } from "@/lib/types";
interface EventCardProps { interface EventCardProps {
event: CalendarEvent event: CalendarEvent;
onEdit: (event: CalendarEvent) => void onEdit: (event: CalendarEvent) => void;
onDelete: (eventId: string) => void onDelete: (eventId: string) => void;
} }
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => { export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => { const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
return allDay return allDay
? new Date(dateStr).toLocaleDateString() ? new Date(dateStr).toLocaleDateString()
: new Date(dateStr).toLocaleString() : new Date(dateStr).toLocaleString();
} };
const handleEdit = () => { const handleEdit = () => {
onEdit({ onEdit({
id: event.id, id: event.id,
title: event.title, title: event.title,
description: event.description || '', description: event.description || "",
location: event.location || '', location: event.location || "",
url: event.url || '', url: event.url || "",
start: event.start, start: event.start,
end: event.end || '', end: event.end || "",
allDay: event.allDay || false allDay: event.allDay || false,
}) });
} };
return ( return (
<Card className="w-full"> <Card className="w-full">
@@ -58,9 +63,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}> <DropdownMenuItem onClick={handleEdit}>Edit</DropdownMenuItem>
Edit
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onDelete(event.id)} onClick={() => onDelete(event.id)}
className="text-destructive" className="text-destructive"
@@ -88,5 +91,5 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} };

View File

@@ -1,30 +1,36 @@
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import {
import { Input } from '@/components/ui/input' Dialog,
import { RecurrencePicker } from '@/components/recurrence-picker' DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { RecurrencePicker } from "@/components/recurrence-picker";
interface EventDialogProps { interface EventDialogProps {
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
editingId: string | null editingId: string | null;
title: string title: string;
setTitle: (title: string) => void setTitle: (title: string) => void;
description: string description: string;
setDescription: (description: string) => void setDescription: (description: string) => void;
location: string location: string;
setLocation: (location: string) => void setLocation: (location: string) => void;
url: string url: string;
setUrl: (url: string) => void setUrl: (url: string) => void;
start: string start: string;
setStart: (start: string) => void setStart: (start: string) => void;
end: string end: string;
setEnd: (end: string) => void setEnd: (end: string) => void;
allDay: boolean allDay: boolean;
setAllDay: (allDay: boolean) => void setAllDay: (allDay: boolean) => void;
recurrenceRule: string | undefined recurrenceRule: string | undefined;
setRecurrenceRule: (rule: string | undefined) => void setRecurrenceRule: (rule: string | undefined) => void;
onSave: () => void onSave: () => void;
onReset: () => void onReset: () => void;
} }
export const EventDialog = ({ export const EventDialog = ({
@@ -48,49 +54,81 @@ export const EventDialog = ({
recurrenceRule, recurrenceRule,
setRecurrenceRule, setRecurrenceRule,
onSave, onSave,
onReset onReset,
}: EventDialogProps) => { }: EventDialogProps) => {
const handleOpenChange = (val: boolean) => { const handleOpenChange = (val: boolean) => {
if (!val) onReset() if (!val) onReset();
onOpenChange(val) onOpenChange(val);
} };
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle> <DialogTitle>{editingId ? "Edit Event" : "Add Event"}</DialogTitle>
</DialogHeader> </DialogHeader>
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} /> <Input
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea <textarea
className="border rounded p-2 w-full" className="border rounded p-2 w-full"
placeholder="Description" placeholder="Description"
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/>
<Input
placeholder="Location"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
<Input
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/> />
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} />
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} />
<RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} /> <RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} />
<label className="flex items-center gap-2 mt-2"> <label className="flex items-center gap-2 mt-2">
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} /> <input
type="checkbox"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
/>
All day event All day event
</label> </label>
{!allDay ? ( {!allDay ? (
<> <>
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} /> <Input
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} /> type="datetime-local"
value={start}
onChange={(e) => setStart(e.target.value)}
/>
<Input
type="datetime-local"
value={end}
onChange={(e) => setEnd(e.target.value)}
/>
</> </>
) : ( ) : (
<> <>
<Input type="date" value={start ? start.split('T')[0] : ''} onChange={e => setStart(e.target.value)} /> <Input
<Input type="date" value={end ? end.split('T')[0] : ''} onChange={e => setEnd(e.target.value)} /> type="date"
value={start ? start.split("T")[0] : ""}
onChange={(e) => setStart(e.target.value)}
/>
<Input
type="date"
value={end ? end.split("T")[0] : ""}
onChange={(e) => setEnd(e.target.value)}
/>
</> </>
)} )}
<DialogFooter> <DialogFooter>
<Button onClick={onSave}>{editingId ? 'Update' : 'Save'}</Button> <Button onClick={onSave}>{editingId ? "Update" : "Save"}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} };

View File

@@ -1,27 +1,31 @@
import { Calendar1Icon } from 'lucide-react' import { Calendar1Icon } from "lucide-react";
import { EventCard } from './event-card' import { EventCard } from "./event-card";
import type { CalendarEvent } from '@/lib/types' import type { CalendarEvent } from "@/lib/types";
interface EventsListProps { interface EventsListProps {
events: CalendarEvent[] events: CalendarEvent[];
onEdit: (event: CalendarEvent) => void onEdit: (event: CalendarEvent) => void;
onDelete: (eventId: string) => void onDelete: (eventId: string) => void;
} }
export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => { export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
if (events.length === 0) { if (events.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<Calendar1Icon className='h-12 w-12 text-muted-foreground mb-4' /> <Calendar1Icon className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-muted-foreground">No events yet</h3> <h3 className="text-lg font-medium text-muted-foreground">
<p className="text-sm text-muted-foreground">Create your first event to get started</p> No events yet
</h3>
<p className="text-sm text-muted-foreground">
Create your first event to get started
</p>
</div> </div>
) );
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{events.map(event => ( {events.map((event) => (
<EventCard <EventCard
key={event.id} key={event.id}
event={event} event={event}
@@ -30,5 +34,5 @@ export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
/> />
))} ))}
</div> </div>
) );
} };

View File

@@ -1,17 +1,23 @@
"use client" "use client";
import type React from "react" import type React from "react";
import { useRef } from "react" import { useRef } from "react";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Calendar } from "lucide-react" import { Calendar } from "lucide-react";
interface IcsFilePickerProps { interface IcsFilePickerProps {
onFileSelect?: (file: File) => void onFileSelect?: (file: File) => void;
className?: string className?: string;
children?: React.ReactNode children?: React.ReactNode;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" variant?:
size?: "default" | "sm" | "lg" | "icon" | "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "icon";
} }
export function IcsFilePicker({ export function IcsFilePicker({
@@ -21,18 +27,18 @@ export function IcsFilePicker({
variant = "default", variant = "default",
size = "default", size = "default",
}: IcsFilePickerProps) { }: IcsFilePickerProps) {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null);
const handleButtonClick = () => { const handleButtonClick = () => {
fileInputRef.current?.click() fileInputRef.current?.click();
} };
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] const file = event.target.files?.[0];
if (file && onFileSelect) { if (file && onFileSelect) {
onFileSelect(file) onFileSelect(file);
}
} }
};
return ( return (
<> <>
@@ -44,7 +50,12 @@ export function IcsFilePicker({
className="hidden" className="hidden"
aria-hidden="true" aria-hidden="true"
/> />
<Button onClick={handleButtonClick} variant={variant} size={size} className={className}> <Button
onClick={handleButtonClick}
variant={variant}
size={size}
className={className}
>
{children || ( {children || (
<> <>
<Calendar className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
@@ -53,6 +64,5 @@ export function IcsFilePicker({
)} )}
</Button> </Button>
</> </>
) );
} }

View File

@@ -1,56 +1,55 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { Moon, Sun, Monitor } from "lucide-react" import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu";
type ThemeIconProps = { type ThemeIconProps = {
theme?: string theme?: string;
} };
const ThemeIcon = ({ theme }: ThemeIconProps) => { const ThemeIcon = ({ theme }: ThemeIconProps) => {
const [mounted, setMounted] = React.useState(false);
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => { React.useEffect(() => {
setMounted(true) setMounted(true);
}, []) }, []);
if (!mounted) { if (!mounted) {
return null return null;
} }
switch (theme) { switch (theme) {
case "light": case "light":
return ( return (
<Sun className="absolute h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" /> <Sun className="absolute h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
) );
case "dark": case "dark":
return ( return (
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
) );
case "system": case "system":
return ( return <Monitor className="absolute h-[1.2rem] w-[1.2rem] scale-100" />;
<Monitor className="absolute h-[1.2rem] w-[1.2rem] scale-100" />
)
default: default:
return (<> return (
<>
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" /> <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
</>) </>
);
} }
} };
export function ModeToggle() { export function ModeToggle() {
const { setTheme, theme } = useTheme() const { setTheme, theme } = useTheme();
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -72,6 +71,5 @@ export function ModeToggle() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) );
} }

View File

@@ -1,65 +1,76 @@
"use client" "use client";
import { useState } from "react" import { useState } from "react";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import {
import { Checkbox } from "@/components/ui/checkbox" Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
type Recurrence = { type Recurrence = {
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY" freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY";
interval: number interval: number;
byDay?: string[] byDay?: string[];
count?: number count?: number;
until?: string until?: string;
} };
interface Props { interface Props {
value?: string value?: string;
onChange: (rrule: string | undefined) => void onChange: (rrule: string | undefined) => void;
} }
export function RecurrencePicker({ value, onChange }: Props) { export function RecurrencePicker({ value, onChange }: Props) {
const [rec, setRec] = useState<Recurrence>(() => { const [rec, setRec] = useState<Recurrence>(() => {
// If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL) // If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL)
if (value) { if (value) {
const parts = Object.fromEntries(value.split(";").map((p) => p.split("="))) const parts = Object.fromEntries(
value.split(";").map((p) => p.split("=")),
);
return { return {
freq: parts.FREQ || "NONE", freq: parts.FREQ || "NONE",
interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1, interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1,
byDay: parts.BYDAY ? parts.BYDAY.split(",") : [], byDay: parts.BYDAY ? parts.BYDAY.split(",") : [],
count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined, count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined,
until: parts.UNTIL, until: parts.UNTIL,
};
} }
} return { freq: "NONE", interval: 1 };
return { freq: "NONE", interval: 1 } });
})
const update = (updates: Partial<Recurrence>) => { const update = (updates: Partial<Recurrence>) => {
const newRec = { ...rec, ...updates } const newRec = { ...rec, ...updates };
setRec(newRec) setRec(newRec);
if (newRec.freq === "NONE") { if (newRec.freq === "NONE") {
onChange(undefined) onChange(undefined);
return return;
} }
// Build RRULE string // Build RRULE string
let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}` let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}`;
if (newRec.freq === "WEEKLY" && newRec.byDay?.length) { if (newRec.freq === "WEEKLY" && newRec.byDay?.length) {
rrule += `;BYDAY=${newRec.byDay.join(",")}` rrule += `;BYDAY=${newRec.byDay.join(",")}`;
} }
if (newRec.count) rrule += `;COUNT=${newRec.count}` if (newRec.count) rrule += `;COUNT=${newRec.count}`;
if (newRec.until) rrule += `;UNTIL=${newRec.until.replace(/-/g, "")}T000000Z` if (newRec.until)
rrule += `;UNTIL=${newRec.until.replace(/-/g, "")}T000000Z`;
onChange(rrule) onChange(rrule);
} };
const toggleDay = (day: string) => { const toggleDay = (day: string) => {
const byDay = rec.byDay || [] const byDay = rec.byDay || [];
const newByDay = byDay.includes(day) ? byDay.filter((d) => d !== day) : [...byDay, day] const newByDay = byDay.includes(day)
update({ byDay: newByDay }) ? byDay.filter((d) => d !== day)
} : [...byDay, day];
update({ byDay: newByDay });
};
const dayLabels = { const dayLabels = {
MO: "Mon", MO: "Mon",
@@ -69,13 +80,20 @@ export function RecurrencePicker({ value, onChange }: Props) {
FR: "Fri", FR: "Fri",
SA: "Sat", SA: "Sat",
SU: "Sun", SU: "Sun",
} };
return ( return (
<div className=""> <div className="">
<Label htmlFor="frequency" className="pt-4 pb-2 pl-1">Repeats</Label> <Label htmlFor="frequency" className="pt-4 pb-2 pl-1">
Repeats
</Label>
<div className="space-y-2"> <div className="space-y-2">
<Select value={rec.freq} onValueChange={(value) => update({ freq: value as Recurrence["freq"] })}> <Select
value={rec.freq}
onValueChange={(value) =>
update({ freq: value as Recurrence["freq"] })
}
>
<SelectTrigger id="frequency"> <SelectTrigger id="frequency">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -92,7 +110,12 @@ export function RecurrencePicker({ value, onChange }: Props) {
<> <>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="interval"> <Label htmlFor="interval">
Interval (every {rec.interval} {rec.freq === "DAILY" ? "day" : rec.freq === "WEEKLY" ? "week" : "month"} Interval (every {rec.interval}{" "}
{rec.freq === "DAILY"
? "day"
: rec.freq === "WEEKLY"
? "week"
: "month"}
{rec.interval > 1 ? "s" : ""}) {rec.interval > 1 ? "s" : ""})
</Label> </Label>
<Input <Input
@@ -100,7 +123,9 @@ export function RecurrencePicker({ value, onChange }: Props) {
type="number" type="number"
min={1} min={1}
value={rec.interval} value={rec.interval}
onChange={(e) => update({ interval: Number.parseInt(e.target.value, 10) || 1 })} onChange={(e) =>
update({ interval: Number.parseInt(e.target.value, 10) || 1 })
}
className="w-24" className="w-24"
/> />
</div> </div>
@@ -133,7 +158,13 @@ export function RecurrencePicker({ value, onChange }: Props) {
type="number" type="number"
placeholder="e.g. 10" placeholder="e.g. 10"
value={rec.count || ""} value={rec.count || ""}
onChange={(e) => update({ count: e.target.value ? Number.parseInt(e.target.value, 10) : undefined })} onChange={(e) =>
update({
count: e.target.value
? Number.parseInt(e.target.value, 10)
: undefined,
})
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -149,5 +180,5 @@ export function RecurrencePicker({ value, onChange }: Props) {
</> </>
)} )}
</div> </div>
) );
} }

View File

@@ -1,32 +1,38 @@
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge";
import type { RecurrenceRule } from "@/lib/rfc5545-types" import type { RecurrenceRule } from "@/lib/rfc5545-types";
interface RRuleDisplayProps { interface RRuleDisplayProps {
rrule: string | RecurrenceRule rrule: string | RecurrenceRule;
className?: string className?: string;
} }
export function RRuleDisplay({ rrule, className }: RRuleDisplayProps) { export function RRuleDisplay({ rrule, className }: RRuleDisplayProps) {
const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule const parsedRule =
const humanText = formatRRuleToHuman(parsedRule) typeof rrule === "string" ? parseRRuleString(rrule) : rrule;
const humanText = formatRRuleToHuman(parsedRule);
return ( return (
<div className={className}> <div className={className}>
<span className="text-sm text-muted-foreground">{humanText}</span> <span className="text-sm text-muted-foreground">{humanText}</span>
</div> </div>
) );
} }
interface RRuleDisplayDetailedProps { interface RRuleDisplayDetailedProps {
rrule: string | RecurrenceRule rrule: string | RecurrenceRule;
className?: string className?: string;
showBadges?: boolean showBadges?: boolean;
} }
export function RRuleDisplayDetailed({ rrule, className, showBadges = true }: RRuleDisplayDetailedProps) { export function RRuleDisplayDetailed({
const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule rrule,
const humanText = formatRRuleToHuman(parsedRule) className,
const details = getRRuleDetails(parsedRule) showBadges = true,
}: RRuleDisplayDetailedProps) {
const parsedRule =
typeof rrule === "string" ? parseRRuleString(rrule) : rrule;
const humanText = formatRRuleToHuman(parsedRule);
const details = getRRuleDetails(parsedRule);
return ( return (
<div className={className}> <div className={className}>
@@ -44,201 +50,257 @@ export function RRuleDisplayDetailed({ rrule, className, showBadges = true }: RR
)} )}
</div> </div>
</div> </div>
) );
} }
function parseRRuleString(rruleString: string): RecurrenceRule { function parseRRuleString(rruleString: string): RecurrenceRule {
const parts = Object.fromEntries(rruleString.split(";").map(p => p.split("="))) const parts = Object.fromEntries(
rruleString.split(";").map((p) => p.split("=")),
);
return { return {
freq: parts.FREQ as RecurrenceRule['freq'], freq: parts.FREQ as RecurrenceRule["freq"],
until: parts.UNTIL ? new Date(parts.UNTIL.replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/, '$1-$2-$3T$4:$5:$6Z')).toISOString() : undefined, until: parts.UNTIL
? new Date(
parts.UNTIL.replace(
/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/,
"$1-$2-$3T$4:$5:$6Z",
),
).toISOString()
: undefined,
count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined, count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined,
interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined, interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined,
bySecond: parts.BYSECOND ? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10)) : undefined, bySecond: parts.BYSECOND
byMinute: parts.BYMINUTE ? parts.BYMINUTE.split(",").map((n: string) => parseInt(n, 10)) : undefined, ? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10))
byHour: parts.BYHOUR ? parts.BYHOUR.split(",").map((n: string) => parseInt(n, 10)) : undefined, : undefined,
byMinute: parts.BYMINUTE
? parts.BYMINUTE.split(",").map((n: string) => parseInt(n, 10))
: undefined,
byHour: parts.BYHOUR
? parts.BYHOUR.split(",").map((n: string) => parseInt(n, 10))
: undefined,
byDay: parts.BYDAY ? parts.BYDAY.split(",") : undefined, byDay: parts.BYDAY ? parts.BYDAY.split(",") : undefined,
byMonthDay: parts.BYMONTHDAY ? parts.BYMONTHDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined, byMonthDay: parts.BYMONTHDAY
byYearDay: parts.BYYEARDAY ? parts.BYYEARDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined, ? parts.BYMONTHDAY.split(",").map((n: string) => parseInt(n, 10))
byWeekNo: parts.BYWEEKNO ? parts.BYWEEKNO.split(",").map((n: string) => parseInt(n, 10)) : undefined, : undefined,
byMonth: parts.BYMONTH ? parts.BYMONTH.split(",").map((n: string) => parseInt(n, 10)) : undefined, byYearDay: parts.BYYEARDAY
bySetPos: parts.BYSETPOS ? parts.BYSETPOS.split(",").map((n: string) => parseInt(n, 10)) : undefined, ? parts.BYYEARDAY.split(",").map((n: string) => parseInt(n, 10))
wkst: parts.WKST as RecurrenceRule['wkst'], : undefined,
} byWeekNo: parts.BYWEEKNO
? parts.BYWEEKNO.split(",").map((n: string) => parseInt(n, 10))
: undefined,
byMonth: parts.BYMONTH
? parts.BYMONTH.split(",").map((n: string) => parseInt(n, 10))
: undefined,
bySetPos: parts.BYSETPOS
? parts.BYSETPOS.split(",").map((n: string) => parseInt(n, 10))
: undefined,
wkst: parts.WKST as RecurrenceRule["wkst"],
};
} }
function formatRRuleToHuman(rule: RecurrenceRule): string { function formatRRuleToHuman(rule: RecurrenceRule): string {
const { freq, interval = 1, count, until, byDay, byMonthDay, byMonth, byHour, byMinute, bySecond } = rule const {
freq,
interval = 1,
count,
until,
byDay,
byMonthDay,
byMonth,
byHour,
byMinute,
bySecond,
} = rule;
let text = "" let text = "";
// Base frequency // Base frequency
switch (freq) { switch (freq) {
case 'SECONDLY': case "SECONDLY":
text = interval === 1 ? "Every second" : `Every ${interval} seconds` text = interval === 1 ? "Every second" : `Every ${interval} seconds`;
break break;
case 'MINUTELY': case "MINUTELY":
text = interval === 1 ? "Every minute" : `Every ${interval} minutes` text = interval === 1 ? "Every minute" : `Every ${interval} minutes`;
break break;
case 'HOURLY': case "HOURLY":
text = interval === 1 ? "Every hour" : `Every ${interval} hours` text = interval === 1 ? "Every hour" : `Every ${interval} hours`;
break break;
case 'DAILY': case "DAILY":
text = interval === 1 ? "Daily" : `Every ${interval} days` text = interval === 1 ? "Daily" : `Every ${interval} days`;
break break;
case 'WEEKLY': case "WEEKLY":
text = interval === 1 ? "Weekly" : `Every ${interval} weeks` text = interval === 1 ? "Weekly" : `Every ${interval} weeks`;
break break;
case 'MONTHLY': case "MONTHLY":
text = interval === 1 ? "Monthly" : `Every ${interval} months` text = interval === 1 ? "Monthly" : `Every ${interval} months`;
break break;
case 'YEARLY': case "YEARLY":
text = interval === 1 ? "Yearly" : `Every ${interval} years` text = interval === 1 ? "Yearly" : `Every ${interval} years`;
break break;
} }
// Add day specifications // Add day specifications
if (byDay?.length) { if (byDay?.length) {
const dayNames = { const dayNames = {
'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday', SU: "Sunday",
'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday' MO: "Monday",
} TU: "Tuesday",
WE: "Wednesday",
TH: "Thursday",
FR: "Friday",
SA: "Saturday",
};
const days = byDay.map(day => { const days = byDay.map((day) => {
// Handle numbered days like "2TU" (second Tuesday) // Handle numbered days like "2TU" (second Tuesday)
const match = day.match(/^(-?\d+)?([A-Z]{2})$/) const match = day.match(/^(-?\d+)?([A-Z]{2})$/);
if (match) { if (match) {
const [, num, dayCode] = match const [, num, dayCode] = match;
const dayName = dayNames[dayCode as keyof typeof dayNames] const dayName = dayNames[dayCode as keyof typeof dayNames];
if (num) { if (num) {
const ordinal = getOrdinal(parseInt(num)) const ordinal = getOrdinal(parseInt(num));
return `${ordinal} ${dayName}` return `${ordinal} ${dayName}`;
} }
return dayName return dayName;
} }
return day return day;
}) });
if (freq === 'WEEKLY') { if (freq === "WEEKLY") {
text += ` on ${formatList(days)}` text += ` on ${formatList(days)}`;
} else { } else {
text += ` on ${formatList(days)}` text += ` on ${formatList(days)}`;
} }
} }
// Add month day specifications // Add month day specifications
if (byMonthDay?.length) { if (byMonthDay?.length) {
const days = byMonthDay.map(day => { const days = byMonthDay.map((day) => {
if (day < 0) { if (day < 0) {
return `${getOrdinal(Math.abs(day))} to last day` return `${getOrdinal(Math.abs(day))} to last day`;
} }
return getOrdinal(day) return getOrdinal(day);
}) });
text += ` on the ${formatList(days)}` text += ` on the ${formatList(days)}`;
} }
// Add month specifications // Add month specifications
if (byMonth?.length) { if (byMonth?.length) {
const monthNames = [ const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June', "January",
'July', 'August', 'September', 'October', 'November', 'December' "February",
] "March",
const months = byMonth.map(month => monthNames[month - 1]) "April",
text += ` in ${formatList(months)}` "May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const months = byMonth.map((month) => monthNames[month - 1]);
text += ` in ${formatList(months)}`;
} }
// Add time specifications // Add time specifications
if (byHour?.length || byMinute?.length || bySecond?.length) { if (byHour?.length || byMinute?.length || bySecond?.length) {
const timeSpecs = [] const timeSpecs = [];
if (byHour?.length) { if (byHour?.length) {
const hours = byHour.map(h => `${h.toString().padStart(2, '0')}:00`) const hours = byHour.map((h) => `${h.toString().padStart(2, "0")}:00`);
timeSpecs.push(`at ${formatList(hours)}`) timeSpecs.push(`at ${formatList(hours)}`);
} }
if (byMinute?.length && !byHour?.length) { if (byMinute?.length && !byHour?.length) {
timeSpecs.push(`at minute ${formatList(byMinute.map(String))}`) timeSpecs.push(`at minute ${formatList(byMinute.map(String))}`);
} }
if (bySecond?.length && !byHour?.length && !byMinute?.length) { if (bySecond?.length && !byHour?.length && !byMinute?.length) {
timeSpecs.push(`at second ${formatList(bySecond.map(String))}`) timeSpecs.push(`at second ${formatList(bySecond.map(String))}`);
} }
if (timeSpecs.length) { if (timeSpecs.length) {
text += ` ${timeSpecs.join(' ')}` text += ` ${timeSpecs.join(" ")}`;
} }
} }
// Add end conditions // Add end conditions
if (count) { if (count) {
text += `, ${count} time${count === 1 ? '' : 's'}` text += `, ${count} time${count === 1 ? "" : "s"}`;
} else if (until) { } else if (until) {
const date = new Date(until) const date = new Date(until);
text += `, until ${date.toLocaleDateString()}` text += `, until ${date.toLocaleDateString()}`;
} }
return text return text;
} }
function getRRuleDetails(rule: RecurrenceRule): string[] { function getRRuleDetails(rule: RecurrenceRule): string[] {
const details: string[] = [] const details: string[] = [];
if (rule.wkst && rule.wkst !== 'MO') { if (rule.wkst && rule.wkst !== "MO") {
const dayNames = { const dayNames = {
'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday', SU: "Sunday",
'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday' MO: "Monday",
} TU: "Tuesday",
details.push(`Week starts ${dayNames[rule.wkst]}`) WE: "Wednesday",
TH: "Thursday",
FR: "Friday",
SA: "Saturday",
};
details.push(`Week starts ${dayNames[rule.wkst]}`);
} }
if (rule.byWeekNo?.length) { if (rule.byWeekNo?.length) {
details.push(`Week ${formatList(rule.byWeekNo.map(String))}`) details.push(`Week ${formatList(rule.byWeekNo.map(String))}`);
} }
if (rule.byYearDay?.length) { if (rule.byYearDay?.length) {
details.push(`Day ${formatList(rule.byYearDay.map(String))} of year`) details.push(`Day ${formatList(rule.byYearDay.map(String))} of year`);
} }
if (rule.bySetPos?.length) { if (rule.bySetPos?.length) {
const positions = rule.bySetPos.map(pos => { const positions = rule.bySetPos.map((pos) => {
if (pos < 0) { if (pos < 0) {
return `${getOrdinal(Math.abs(pos))} to last` return `${getOrdinal(Math.abs(pos))} to last`;
} }
return getOrdinal(pos) return getOrdinal(pos);
}) });
details.push(`Position ${formatList(positions)}`) details.push(`Position ${formatList(positions)}`);
} }
return details return details;
} }
function getOrdinal(num: number): string { function getOrdinal(num: number): string {
const suffix = ['th', 'st', 'nd', 'rd'] const suffix = ["th", "st", "nd", "rd"];
const v = num % 100 const v = num % 100;
return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]) return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]);
} }
function formatList(items: string[]): string { function formatList(items: string[]): string {
if (items.length === 0) return '' if (items.length === 0) return "";
if (items.length === 1) return items[0] if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}` if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}` return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
} }
// Hook for easy usage in components // Hook for easy usage in components
export function useRRuleDisplay(rrule?: string) { export function useRRuleDisplay(rrule?: string) {
if (!rrule) return null if (!rrule) return null;
try { try {
const parsedRule = parseRRuleString(rrule) const parsedRule = parseRRuleString(rrule);
return { return {
humanText: formatRRuleToHuman(parsedRule), humanText: formatRRuleToHuman(parsedRule),
details: getRRuleDetails(parsedRule), details: getRRuleDetails(parsedRule),
parsedRule parsedRule,
} };
} catch (error) { } catch (error) {
return { return {
humanText: "Invalid recurrence rule", humanText: "Invalid recurrence rule",
details: [], details: [],
parsedRule: null, parsedRule: null,
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error),
} };
} }
} }

View File

@@ -1,25 +1,25 @@
"use client" "use client";
import { signOut, useSession } from "@/lib/auth-client" import { signOut, useSession } from "@/lib/auth-client";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation";
import { toast } from "sonner" import { toast } from "sonner";
export default function SignIn() { export default function SignIn() {
const { data: session, isPending } = useSession() const { data: session, isPending } = useSession();
const router = useRouter() const router = useRouter();
const handleSignOut = async () => { const handleSignOut = async () => {
try { try {
await signOut() await signOut();
router.push("/") router.push("/");
} catch (_error) { } catch (_error) {
toast.error("Failed to sign out. Please try again.") toast.error("Failed to sign out. Please try again.");
}
} }
};
if (isPending) { if (isPending) {
return <div className="h-8 w-16 bg-muted animate-pulse rounded"></div> return <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>;
} }
if (session?.user) { if (session?.user) {
@@ -29,7 +29,7 @@ export default function SignIn() {
Sign Out Sign Out
</Button> </Button>
</div> </div>
) );
} }
return ( return (
@@ -40,5 +40,5 @@ export default function SignIn() {
> >
Sign In Sign In
</Button> </Button>
) );
} }

View File

@@ -1,11 +1,11 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes" import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ export function ThemeProvider({
children, children,
...props ...props
}: React.ComponentProps<typeof NextThemesProvider>) { }: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider> return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
} }