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,80 +1,84 @@
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 = ({
isAuthenticated, isAuthenticated,
isPending, isPending,
aiPrompt, aiPrompt,
setAiPrompt, setAiPrompt,
aiLoading, aiLoading,
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>
{isAuthenticated ? ( ) : (
<div className="flex flex-col sm:flex-row gap-4 mb-4 items-start"> <div>
<div className='w-full'> {isAuthenticated ? (
<Textarea <div className="flex flex-col sm:flex-row gap-4 mb-4 items-start">
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" <div className="w-full">
style={{ clipPath: "inset(0 round 1rem)" }} <Textarea
placeholder='Describe event for AI to create' 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"
value={aiPrompt} style={{ clipPath: "inset(0 round 1rem)" }}
onChange={e => setAiPrompt(e.target.value)} placeholder="Describe event for AI to create"
/> value={aiPrompt}
</div> onChange={(e) => setAiPrompt(e.target.value)}
<div className='flex flex-row gap-2 pt-1'> />
<Button onClick={onAiCreate} disabled={aiLoading}> </div>
{aiLoading ? 'Thinking...' : 'AI Create'} <div className="flex flex-row gap-2 pt-1">
</Button> <Button onClick={onAiCreate} disabled={aiLoading}>
</div> {aiLoading ? "Thinking..." : "AI Create"}
</div> </Button>
) : ( </div>
<div className="mb-4 p-4 border border-dashed rounded-lg text-center"> </div>
<div className="text-sm text-muted-foreground"> ) : (
Sign in to unlock natural language event creation powered by AI <div className="mb-4 p-4 border border-dashed rounded-lg text-center">
</div> <div className="text-sm text-muted-foreground">
</div> Sign in to unlock natural language event creation powered by AI
)} </div>
</div> </div>
)} )}
</div>
)}
{/* 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>{summary}</div>
</div> </Card>
<div>{summary}</div> )}
</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"
</Button> onClick={onAiSummarize}
</div> disabled={aiLoading}
</> >
) {aiLoading ? "Summarizing..." : "AI Summarize"}
} </Button>
</div>
</>
);
};

View File

@@ -1,55 +1,55 @@
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
onDragOver={handleDragOver} onDragOver={handleDragOver}
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,36 +1,42 @@
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 = ({
events, events,
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">
{events.length > 0 && ( Import .ics
<> </IcsFilePicker>
<Button variant="secondary" onClick={onExport}>Export .ics</Button> {events.length > 0 && (
<Button variant="destructive" onClick={onClearAll}>Clear All</Button> <>
</> <Button variant="secondary" onClick={onExport}>
)} Export .ics
</div> </Button>
</> <Button variant="destructive" onClick={onClearAll}>
) Clear All
} </Button>
</>
)}
</div>
</>
);
};

View File

@@ -1,92 +1,95 @@
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">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-1 flex-1"> <div className="space-y-1 flex-1">
<h3 className="font-semibold leading-none tracking-tight"> <h3 className="font-semibold leading-none tracking-tight">
{event.title} {event.title}
</h3> </h3>
{event.recurrenceRule && ( {event.recurrenceRule && (
<div className="mt-1"> <div className="mt-1">
<RRuleDisplay rrule={event.recurrenceRule} /> <RRuleDisplay rrule={event.recurrenceRule} />
</div> </div>
)} )}
{event.description && ( {event.description && (
<p className="text-sm text-muted-foreground mt-2 break-words"> <p className="text-sm text-muted-foreground mt-2 break-words">
{event.description} {event.description}
</p> </p>
)} )}
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}> <DropdownMenuItem onClick={handleEdit}>Edit</DropdownMenuItem>
Edit <DropdownMenuItem
</DropdownMenuItem> onClick={() => onDelete(event.id)}
<DropdownMenuItem className="text-destructive"
onClick={() => onDelete(event.id)} >
className="text-destructive" Delete
> </DropdownMenuItem>
Delete </DropdownMenuContent>
</DropdownMenuItem> </DropdownMenu>
</DropdownMenuContent> </div>
</DropdownMenu> </CardHeader>
</div>
</CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center text-sm text-muted-foreground"> <div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4" /> <Clock className="mr-2 h-4 w-4" />
{formatDateTime(event.start, event.allDay)} {formatDateTime(event.start, event.allDay)}
</div> </div>
{event.location && ( {event.location && (
<div className="flex items-center text-sm text-muted-foreground"> <div className="flex items-center text-sm text-muted-foreground">
<LucideMapPin className="mr-2 h-4 w-4" /> <LucideMapPin className="mr-2 h-4 w-4" />
{event.location} {event.location}
</div> </div>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} };

View File

@@ -1,96 +1,134 @@
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 = ({
open, open,
onOpenChange, onOpenChange,
editingId, editingId,
title, title,
setTitle, setTitle,
description, description,
setDescription, setDescription,
location, location,
setLocation, setLocation,
url, url,
setUrl, setUrl,
start, start,
setStart, setStart,
end, end,
setEnd, setEnd,
allDay, allDay,
setAllDay, setAllDay,
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
<textarea placeholder="Title"
className="border rounded p-2 w-full" value={title}
placeholder="Description" onChange={(e) => setTitle(e.target.value)}
value={description} />
onChange={e => setDescription(e.target.value)} <textarea
/> className="border rounded p-2 w-full"
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} /> placeholder="Description"
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} /> value={description}
<RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} /> 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)}
/>
<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
All day event type="checkbox"
</label> checked={allDay}
{!allDay ? ( onChange={(e) => setAllDay(e.target.checked)}
<> />
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} /> All day event
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} /> </label>
</> {!allDay ? (
) : ( <>
<> <Input
<Input type="date" value={start ? start.split('T')[0] : ''} onChange={e => setStart(e.target.value)} /> type="datetime-local"
<Input type="date" value={end ? end.split('T')[0] : ''} onChange={e => setEnd(e.target.value)} /> value={start}
</> onChange={(e) => setStart(e.target.value)}
)} />
<DialogFooter> <Input
<Button onClick={onSave}>{editingId ? 'Update' : 'Save'}</Button> type="datetime-local"
</DialogFooter> value={end}
</DialogContent> onChange={(e) => setEnd(e.target.value)}
</Dialog> />
) </>
} ) : (
<>
<Input
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>
<Button onClick={onSave}>{editingId ? "Update" : "Save"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,34 +1,38 @@
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
</div> </h3>
) <p className="text-sm text-muted-foreground">
} Create your first event to get started
</p>
</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}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
/> />
))} ))}
</div> </div>
) );
} };

View File

@@ -1,58 +1,68 @@
"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({
onFileSelect, onFileSelect,
className, className,
children, children,
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 (
<> <>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".ics" accept=".ics"
onChange={handleFileChange} onChange={handleFileChange}
className="hidden" className="hidden"
aria-hidden="true" aria-hidden="true"
/> />
<Button onClick={handleButtonClick} variant={variant} size={size} className={className}> <Button
{children || ( onClick={handleButtonClick}
<> variant={variant}
<Calendar className="mr-2 h-4 w-4" /> size={size}
Import Calendar className={className}
</> >
)} {children || (
</Button> <>
</> <Calendar className="mr-2 h-4 w-4" />
) Import Calendar
</>
)}
</Button>
</>
);
} }

View File

@@ -1,77 +1,75 @@
"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(() => {
setMounted(true);
}, []);
React.useEffect(() => { if (!mounted) {
setMounted(true) return null;
}, []) }
if (!mounted) { switch (theme) {
return null case "light":
} return (
<Sun className="absolute h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
switch (theme) { );
case "light": case "dark":
return ( return (
<Sun className="absolute 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" />
) );
case "dark": case "system":
return ( return <Monitor className="absolute h-[1.2rem] w-[1.2rem] scale-100" />;
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" /> default:
) return (
case "system": <>
return ( <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Monitor className="absolute h-[1.2rem] w-[1.2rem] scale-100" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
) </>
default: );
return (<> }
<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" />
</>)
}
}
export function ModeToggle() { export function ModeToggle() {
const { setTheme, theme } = useTheme() const { setTheme, theme } = useTheme();
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon"> <Button variant="outline" size="icon">
<ThemeIcon theme={theme} /> <ThemeIcon theme={theme} />
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}> <DropdownMenuItem onClick={() => setTheme("light")}>
Light Light
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}> <DropdownMenuItem onClick={() => setTheme("dark")}>
Dark Dark
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}> <DropdownMenuItem onClick={() => setTheme("system")}>
System System
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) );
} }

View File

@@ -1,153 +1,184 @@
"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(
return { value.split(";").map((p) => p.split("=")),
freq: parts.FREQ || "NONE", );
interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1, return {
byDay: parts.BYDAY ? parts.BYDAY.split(",") : [], freq: parts.FREQ || "NONE",
count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined, interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1,
until: parts.UNTIL, byDay: parts.BYDAY ? parts.BYDAY.split(",") : [],
} count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined,
} 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",
TU: "Tue", TU: "Tue",
WE: "Wed", WE: "Wed",
TH: "Thu", TH: "Thu",
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">
<div className="space-y-2"> Repeats
<Select value={rec.freq} onValueChange={(value) => update({ freq: value as Recurrence["freq"] })}> </Label>
<SelectTrigger id="frequency"> <div className="space-y-2">
<SelectValue /> <Select
</SelectTrigger> value={rec.freq}
<SelectContent> onValueChange={(value) =>
<SelectItem value="NONE">Does not repeat</SelectItem> update({ freq: value as Recurrence["freq"] })
<SelectItem value="DAILY">Daily</SelectItem> }
<SelectItem value="WEEKLY">Weekly</SelectItem> >
<SelectItem value="MONTHLY">Monthly</SelectItem> <SelectTrigger id="frequency">
</SelectContent> <SelectValue />
</Select> </SelectTrigger>
</div> <SelectContent>
<SelectItem value="NONE">Does not repeat</SelectItem>
<SelectItem value="DAILY">Daily</SelectItem>
<SelectItem value="WEEKLY">Weekly</SelectItem>
<SelectItem value="MONTHLY">Monthly</SelectItem>
</SelectContent>
</Select>
</div>
{rec.freq !== "NONE" && ( {rec.freq !== "NONE" && (
<> <>
<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.interval > 1 ? "s" : ""}) {rec.freq === "DAILY"
</Label> ? "day"
<Input : rec.freq === "WEEKLY"
id="interval" ? "week"
type="number" : "month"}
min={1} {rec.interval > 1 ? "s" : ""})
value={rec.interval} </Label>
onChange={(e) => update({ interval: Number.parseInt(e.target.value, 10) || 1 })} <Input
className="w-24" id="interval"
/> type="number"
</div> min={1}
value={rec.interval}
onChange={(e) =>
update({ interval: Number.parseInt(e.target.value, 10) || 1 })
}
className="w-24"
/>
</div>
{rec.freq === "WEEKLY" && ( {rec.freq === "WEEKLY" && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Days of the week</Label> <Label>Days of the week</Label>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
{["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((day) => ( {["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((day) => (
<div key={day} className="flex items-center space-x-2"> <div key={day} className="flex items-center space-x-2">
<Checkbox <Checkbox
id={day} id={day}
checked={rec.byDay?.includes(day) || false} checked={rec.byDay?.includes(day) || false}
onCheckedChange={() => toggleDay(day)} onCheckedChange={() => toggleDay(day)}
/> />
<Label htmlFor={day} className="text-sm font-normal"> <Label htmlFor={day} className="text-sm font-normal">
{dayLabels[day as keyof typeof dayLabels]} {dayLabels[day as keyof typeof dayLabels]}
</Label> </Label>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="count">End after (occurrences)</Label> <Label htmlFor="count">End after (occurrences)</Label>
<Input <Input
id="count" id="count"
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({
</div> count: e.target.value
<div className="space-y-2"> ? Number.parseInt(e.target.value, 10)
<Label htmlFor="until">End by date</Label> : undefined,
<Input })
id="until" }
type="date" />
value={rec.until || ""} </div>
onChange={(e) => update({ until: e.target.value || undefined })} <div className="space-y-2">
/> <Label htmlFor="until">End by date</Label>
</div> <Input
</div> id="until"
</> type="date"
)} value={rec.until || ""}
</div> onChange={(e) => update({ until: e.target.value || undefined })}
) />
</div>
</div>
</>
)}
</div>
);
} }

View File

@@ -1,244 +1,306 @@
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}>
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium">{humanText}</div> <div className="text-sm font-medium">{humanText}</div>
{showBadges && details.length > 0 && ( {showBadges && details.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{details.map((detail, index) => ( {details.map((detail, index) => (
<Badge key={index} variant="outline" className="text-xs"> <Badge key={index} variant="outline" className="text-xs">
{detail} {detail}
</Badge> </Badge>
))} ))}
</div> </div>
)} )}
</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
count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined, ? new Date(
interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined, parts.UNTIL.replace(
bySecond: parts.BYSECOND ? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10)) : undefined, /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/,
byMinute: parts.BYMINUTE ? parts.BYMINUTE.split(",").map((n: string) => parseInt(n, 10)) : undefined, "$1-$2-$3T$4:$5:$6Z",
byHour: parts.BYHOUR ? parts.BYHOUR.split(",").map((n: string) => parseInt(n, 10)) : undefined, ),
byDay: parts.BYDAY ? parts.BYDAY.split(",") : undefined, ).toISOString()
byMonthDay: parts.BYMONTHDAY ? parts.BYMONTHDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined, : undefined,
byYearDay: parts.BYYEARDAY ? parts.BYYEARDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined, count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined,
byWeekNo: parts.BYWEEKNO ? parts.BYWEEKNO.split(",").map((n: string) => parseInt(n, 10)) : undefined, interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined,
byMonth: parts.BYMONTH ? parts.BYMONTH.split(",").map((n: string) => parseInt(n, 10)) : undefined, bySecond: parts.BYSECOND
bySetPos: parts.BYSETPOS ? parts.BYSETPOS.split(",").map((n: string) => parseInt(n, 10)) : undefined, ? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10))
wkst: parts.WKST as RecurrenceRule['wkst'], : 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,
byMonthDay: parts.BYMONTHDAY
? parts.BYMONTHDAY.split(",").map((n: string) => parseInt(n, 10))
: undefined,
byYearDay: parts.BYYEARDAY
? parts.BYYEARDAY.split(",").map((n: string) => parseInt(n, 10))
: 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,44 +1,44 @@
"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) {
return ( return (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button onClick={handleSignOut} variant="ghost" size="default"> <Button onClick={handleSignOut} variant="ghost" size="default">
Sign Out Sign Out
</Button> </Button>
</div> </div>
) );
} }
return ( return (
<Button <Button
onClick={() => router.push("/auth/signin")} onClick={() => router.push("/auth/signin")}
variant="outline" variant="outline"
size="default" size="default"
> >
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>;
} }