style(components): standardize main component file formatting
This commit is contained in:
@@ -1,80 +1,84 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface AIToolbarProps {
|
||||
isAuthenticated: boolean
|
||||
isPending: boolean
|
||||
aiPrompt: string
|
||||
setAiPrompt: (prompt: string) => void
|
||||
aiLoading: boolean
|
||||
onAiCreate: () => void
|
||||
onAiSummarize: () => void
|
||||
summary: string | null
|
||||
summaryUpdated: string | null
|
||||
isAuthenticated: boolean;
|
||||
isPending: boolean;
|
||||
aiPrompt: string;
|
||||
setAiPrompt: (prompt: string) => void;
|
||||
aiLoading: boolean;
|
||||
onAiCreate: () => void;
|
||||
onAiSummarize: () => void;
|
||||
summary: string | null;
|
||||
summaryUpdated: string | null;
|
||||
}
|
||||
|
||||
export const AIToolbar = ({
|
||||
isAuthenticated,
|
||||
isPending,
|
||||
aiPrompt,
|
||||
setAiPrompt,
|
||||
aiLoading,
|
||||
onAiCreate,
|
||||
onAiSummarize,
|
||||
summary,
|
||||
summaryUpdated
|
||||
isAuthenticated,
|
||||
isPending,
|
||||
aiPrompt,
|
||||
setAiPrompt,
|
||||
aiLoading,
|
||||
onAiCreate,
|
||||
onAiSummarize,
|
||||
summary,
|
||||
summaryUpdated,
|
||||
}: AIToolbarProps) => {
|
||||
return (
|
||||
<>
|
||||
{isPending ? (
|
||||
<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 className='w-full'>
|
||||
<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"
|
||||
style={{ clipPath: "inset(0 round 1rem)" }}
|
||||
placeholder='Describe event for AI to create'
|
||||
value={aiPrompt}
|
||||
onChange={e => setAiPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-row gap-2 pt-1'>
|
||||
<Button onClick={onAiCreate} disabled={aiLoading}>
|
||||
{aiLoading ? 'Thinking...' : 'AI Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4 p-4 border border-dashed rounded-lg text-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Sign in to unlock natural language event creation powered by AI
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
{isPending ? (
|
||||
<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 className="w-full">
|
||||
<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"
|
||||
style={{ clipPath: "inset(0 round 1rem)" }}
|
||||
placeholder="Describe event for AI to create"
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 pt-1">
|
||||
<Button onClick={onAiCreate} disabled={aiLoading}>
|
||||
{aiLoading ? "Thinking..." : "AI Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4 p-4 border border-dashed rounded-lg text-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Sign in to unlock natural language event creation powered by AI
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Panel */}
|
||||
{summary && (
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="text-sm mb-1">
|
||||
Summary updated {summaryUpdated}
|
||||
</div>
|
||||
<div>{summary}</div>
|
||||
</Card>
|
||||
)}
|
||||
{/* Summary Panel */}
|
||||
{summary && (
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="text-sm mb-1">Summary updated {summaryUpdated}</div>
|
||||
<div>{summary}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI Actions Toolbar */}
|
||||
<p className='text-muted-foreground text-sm pb-2 pl-1'>AI actions</p>
|
||||
<div className="gap-2 mb-4">
|
||||
<Button variant="secondary" onClick={onAiSummarize} disabled={aiLoading}>
|
||||
{aiLoading ? 'Summarizing...' : 'AI Summarize'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{/* AI Actions Toolbar */}
|
||||
<p className="text-muted-foreground text-sm pb-2 pl-1">AI actions</p>
|
||||
<div className="gap-2 mb-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onAiSummarize}
|
||||
disabled={aiLoading}
|
||||
>
|
||||
{aiLoading ? "Summarizing..." : "AI Summarize"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DragDropContainerProps {
|
||||
children: ReactNode
|
||||
isDragOver: boolean
|
||||
setIsDragOver: (isDragOver: boolean) => void
|
||||
onImport: (file: File) => void
|
||||
children: ReactNode;
|
||||
isDragOver: boolean;
|
||||
setIsDragOver: (isDragOver: boolean) => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export const DragDropContainer = ({
|
||||
children,
|
||||
isDragOver,
|
||||
setIsDragOver,
|
||||
onImport
|
||||
children,
|
||||
isDragOver,
|
||||
setIsDragOver,
|
||||
onImport,
|
||||
}: DragDropContainerProps) => {
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(true)
|
||||
}
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
}
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
if (e.dataTransfer.files?.length) {
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file.name.endsWith('.ics')) {
|
||||
onImport(file)
|
||||
} else {
|
||||
toast.warning('Please drop an .ics file')
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (e.dataTransfer.files?.length) {
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file.name.endsWith(".ics")) {
|
||||
onImport(file);
|
||||
} else {
|
||||
toast.warning("Please drop an .ics file");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
<div className='mt-auto w-full pb-4 text-gray-400'>
|
||||
<div className='max-w-fit m-auto'>Drag & Drop *.ics here</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
<div className="mt-auto w-full pb-4 text-gray-400">
|
||||
<div className="max-w-fit m-auto">Drag & Drop *.ics here</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IcsFilePicker } from '@/components/ics-file-picker'
|
||||
import type { CalendarEvent } from '@/lib/types'
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IcsFilePicker } from "@/components/ics-file-picker";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
interface EventActionsToolbarProps {
|
||||
events: CalendarEvent[]
|
||||
onAddEvent: () => void
|
||||
onImport: (file: File) => void
|
||||
onExport: () => void
|
||||
onClearAll: () => void
|
||||
events: CalendarEvent[];
|
||||
onAddEvent: () => void;
|
||||
onImport: (file: File) => void;
|
||||
onExport: () => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export const EventActionsToolbar = ({
|
||||
events,
|
||||
onAddEvent,
|
||||
onImport,
|
||||
onExport,
|
||||
onClearAll
|
||||
events,
|
||||
onAddEvent,
|
||||
onImport,
|
||||
onExport,
|
||||
onClearAll,
|
||||
}: EventActionsToolbarProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Control Toolbar */}
|
||||
<p className='text-muted-foreground text-sm pb-2 pl-1'>Event Actions</p>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Button onClick={onAddEvent}>Add Event</Button>
|
||||
<IcsFilePicker onFileSelect={onImport} variant='secondary'>Import .ics</IcsFilePicker>
|
||||
{events.length > 0 && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onExport}>Export .ics</Button>
|
||||
<Button variant="destructive" onClick={onClearAll}>Clear All</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* Control Toolbar */}
|
||||
<p className="text-muted-foreground text-sm pb-2 pl-1">Event Actions</p>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Button onClick={onAddEvent}>Add Event</Button>
|
||||
<IcsFilePicker onFileSelect={onImport} variant="secondary">
|
||||
Import .ics
|
||||
</IcsFilePicker>
|
||||
{events.length > 0 && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onExport}>
|
||||
Export .ics
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onClearAll}>
|
||||
Clear All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,92 +1,95 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card'
|
||||
import { LucideMapPin, Clock, MoreHorizontal } from 'lucide-react'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { RRuleDisplay } from '@/components/rrule-display'
|
||||
import type { CalendarEvent } from '@/lib/types'
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { LucideMapPin, Clock, MoreHorizontal } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { RRuleDisplay } from "@/components/rrule-display";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent
|
||||
onEdit: (event: CalendarEvent) => void
|
||||
onDelete: (eventId: string) => void
|
||||
event: CalendarEvent;
|
||||
onEdit: (event: CalendarEvent) => void;
|
||||
onDelete: (eventId: string) => void;
|
||||
}
|
||||
|
||||
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
|
||||
return allDay
|
||||
? new Date(dateStr).toLocaleDateString()
|
||||
: new Date(dateStr).toLocaleString()
|
||||
}
|
||||
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
|
||||
return allDay
|
||||
? new Date(dateStr).toLocaleDateString()
|
||||
: new Date(dateStr).toLocaleString();
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
description: event.description || '',
|
||||
location: event.location || '',
|
||||
url: event.url || '',
|
||||
start: event.start,
|
||||
end: event.end || '',
|
||||
allDay: event.allDay || false
|
||||
})
|
||||
}
|
||||
const handleEdit = () => {
|
||||
onEdit({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
description: event.description || "",
|
||||
location: event.location || "",
|
||||
url: event.url || "",
|
||||
start: event.start,
|
||||
end: event.end || "",
|
||||
allDay: event.allDay || false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1 flex-1">
|
||||
<h3 className="font-semibold leading-none tracking-tight">
|
||||
{event.title}
|
||||
</h3>
|
||||
{event.recurrenceRule && (
|
||||
<div className="mt-1">
|
||||
<RRuleDisplay rrule={event.recurrenceRule} />
|
||||
</div>
|
||||
)}
|
||||
{event.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2 break-words">
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(event.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1 flex-1">
|
||||
<h3 className="font-semibold leading-none tracking-tight">
|
||||
{event.title}
|
||||
</h3>
|
||||
{event.recurrenceRule && (
|
||||
<div className="mt-1">
|
||||
<RRuleDisplay rrule={event.recurrenceRule} />
|
||||
</div>
|
||||
)}
|
||||
{event.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2 break-words">
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(event.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
{formatDateTime(event.start, event.allDay)}
|
||||
</div>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
{formatDateTime(event.start, event.allDay)}
|
||||
</div>
|
||||
|
||||
{event.location && (
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<LucideMapPin className="mr-2 h-4 w-4" />
|
||||
{event.location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
{event.location && (
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<LucideMapPin className="mr-2 h-4 w-4" />
|
||||
{event.location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,96 +1,134 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { RecurrencePicker } from '@/components/recurrence-picker'
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RecurrencePicker } from "@/components/recurrence-picker";
|
||||
|
||||
interface EventDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editingId: string | null
|
||||
title: string
|
||||
setTitle: (title: string) => void
|
||||
description: string
|
||||
setDescription: (description: string) => void
|
||||
location: string
|
||||
setLocation: (location: string) => void
|
||||
url: string
|
||||
setUrl: (url: string) => void
|
||||
start: string
|
||||
setStart: (start: string) => void
|
||||
end: string
|
||||
setEnd: (end: string) => void
|
||||
allDay: boolean
|
||||
setAllDay: (allDay: boolean) => void
|
||||
recurrenceRule: string | undefined
|
||||
setRecurrenceRule: (rule: string | undefined) => void
|
||||
onSave: () => void
|
||||
onReset: () => void
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingId: string | null;
|
||||
title: string;
|
||||
setTitle: (title: string) => void;
|
||||
description: string;
|
||||
setDescription: (description: string) => void;
|
||||
location: string;
|
||||
setLocation: (location: string) => void;
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
start: string;
|
||||
setStart: (start: string) => void;
|
||||
end: string;
|
||||
setEnd: (end: string) => void;
|
||||
allDay: boolean;
|
||||
setAllDay: (allDay: boolean) => void;
|
||||
recurrenceRule: string | undefined;
|
||||
setRecurrenceRule: (rule: string | undefined) => void;
|
||||
onSave: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export const EventDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingId,
|
||||
title,
|
||||
setTitle,
|
||||
description,
|
||||
setDescription,
|
||||
location,
|
||||
setLocation,
|
||||
url,
|
||||
setUrl,
|
||||
start,
|
||||
setStart,
|
||||
end,
|
||||
setEnd,
|
||||
allDay,
|
||||
setAllDay,
|
||||
recurrenceRule,
|
||||
setRecurrenceRule,
|
||||
onSave,
|
||||
onReset
|
||||
open,
|
||||
onOpenChange,
|
||||
editingId,
|
||||
title,
|
||||
setTitle,
|
||||
description,
|
||||
setDescription,
|
||||
location,
|
||||
setLocation,
|
||||
url,
|
||||
setUrl,
|
||||
start,
|
||||
setStart,
|
||||
end,
|
||||
setEnd,
|
||||
allDay,
|
||||
setAllDay,
|
||||
recurrenceRule,
|
||||
setRecurrenceRule,
|
||||
onSave,
|
||||
onReset,
|
||||
}: EventDialogProps) => {
|
||||
const handleOpenChange = (val: boolean) => {
|
||||
if (!val) onReset()
|
||||
onOpenChange(val)
|
||||
}
|
||||
const handleOpenChange = (val: boolean) => {
|
||||
if (!val) onReset();
|
||||
onOpenChange(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
|
||||
<textarea
|
||||
className="border rounded p-2 w-full"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
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} />
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "Edit Event" : "Add Event"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="border rounded p-2 w-full"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
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">
|
||||
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
|
||||
All day event
|
||||
</label>
|
||||
{!allDay ? (
|
||||
<>
|
||||
<Input 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 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>
|
||||
)
|
||||
}
|
||||
<label className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allDay}
|
||||
onChange={(e) => setAllDay(e.target.checked)}
|
||||
/>
|
||||
All day event
|
||||
</label>
|
||||
{!allDay ? (
|
||||
<>
|
||||
<Input
|
||||
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
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
import { Calendar1Icon } from 'lucide-react'
|
||||
import { EventCard } from './event-card'
|
||||
import type { CalendarEvent } from '@/lib/types'
|
||||
import { Calendar1Icon } from "lucide-react";
|
||||
import { EventCard } from "./event-card";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
interface EventsListProps {
|
||||
events: CalendarEvent[]
|
||||
onEdit: (event: CalendarEvent) => void
|
||||
onDelete: (eventId: string) => void
|
||||
events: CalendarEvent[];
|
||||
onEdit: (event: CalendarEvent) => void;
|
||||
onDelete: (eventId: string) => void;
|
||||
}
|
||||
|
||||
export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Calendar1Icon className='h-12 w-12 text-muted-foreground mb-4' />
|
||||
<h3 className="text-lg font-medium text-muted-foreground">No events yet</h3>
|
||||
<p className="text-sm text-muted-foreground">Create your first event to get started</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Calendar1Icon className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-muted-foreground">
|
||||
No events yet
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create your first event to get started
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{events.map(event => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{events.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,58 +1,68 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import type React from "react"
|
||||
import type React from "react";
|
||||
|
||||
import { useRef } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "lucide-react"
|
||||
import { useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
interface IcsFilePickerProps {
|
||||
onFileSelect?: (file: File) => void
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
onFileSelect?: (file: File) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
}
|
||||
|
||||
export function IcsFilePicker({
|
||||
onFileSelect,
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
onFileSelect,
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
}: IcsFilePickerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleButtonClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
const handleButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file && onFileSelect) {
|
||||
onFileSelect(file)
|
||||
}
|
||||
}
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && onFileSelect) {
|
||||
onFileSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".ics"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button onClick={handleButtonClick} variant={variant} size={size} className={className}>
|
||||
{children || (
|
||||
<>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Import Calendar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".ics"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleButtonClick}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
>
|
||||
{children || (
|
||||
<>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Import Calendar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,77 +1,75 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Moon, Sun, Monitor } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import * as React from "react";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
type ThemeIconProps = {
|
||||
theme?: string
|
||||
}
|
||||
theme?: string;
|
||||
};
|
||||
|
||||
const ThemeIcon = ({ theme }: ThemeIconProps) => {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return (
|
||||
<Sun className="absolute h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
)
|
||||
case "dark":
|
||||
return (
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
)
|
||||
case "system":
|
||||
return (
|
||||
<Monitor className="absolute h-[1.2rem] w-[1.2rem] scale-100" />
|
||||
)
|
||||
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" />
|
||||
</>)
|
||||
}
|
||||
}
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return (
|
||||
<Sun className="absolute h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
);
|
||||
case "dark":
|
||||
return (
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
);
|
||||
case "system":
|
||||
return <Monitor className="absolute h-[1.2rem] w-[1.2rem] scale-100" />;
|
||||
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() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<ThemeIcon theme={theme} />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<ThemeIcon theme={theme} />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,153 +1,184 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
type Recurrence = {
|
||||
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"
|
||||
interval: number
|
||||
byDay?: string[]
|
||||
count?: number
|
||||
until?: string
|
||||
}
|
||||
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY";
|
||||
interval: number;
|
||||
byDay?: string[];
|
||||
count?: number;
|
||||
until?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
value?: string
|
||||
onChange: (rrule: string | undefined) => void
|
||||
value?: string;
|
||||
onChange: (rrule: string | undefined) => void;
|
||||
}
|
||||
|
||||
export function RecurrencePicker({ value, onChange }: Props) {
|
||||
const [rec, setRec] = useState<Recurrence>(() => {
|
||||
// If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL)
|
||||
if (value) {
|
||||
const parts = Object.fromEntries(value.split(";").map((p) => p.split("=")))
|
||||
return {
|
||||
freq: parts.FREQ || "NONE",
|
||||
interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1,
|
||||
byDay: parts.BYDAY ? parts.BYDAY.split(",") : [],
|
||||
count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined,
|
||||
until: parts.UNTIL,
|
||||
}
|
||||
}
|
||||
return { freq: "NONE", interval: 1 }
|
||||
})
|
||||
const [rec, setRec] = useState<Recurrence>(() => {
|
||||
// If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL)
|
||||
if (value) {
|
||||
const parts = Object.fromEntries(
|
||||
value.split(";").map((p) => p.split("=")),
|
||||
);
|
||||
return {
|
||||
freq: parts.FREQ || "NONE",
|
||||
interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1,
|
||||
byDay: parts.BYDAY ? parts.BYDAY.split(",") : [],
|
||||
count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined,
|
||||
until: parts.UNTIL,
|
||||
};
|
||||
}
|
||||
return { freq: "NONE", interval: 1 };
|
||||
});
|
||||
|
||||
const update = (updates: Partial<Recurrence>) => {
|
||||
const newRec = { ...rec, ...updates }
|
||||
setRec(newRec)
|
||||
const update = (updates: Partial<Recurrence>) => {
|
||||
const newRec = { ...rec, ...updates };
|
||||
setRec(newRec);
|
||||
|
||||
if (newRec.freq === "NONE") {
|
||||
onChange(undefined)
|
||||
return
|
||||
}
|
||||
if (newRec.freq === "NONE") {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build RRULE string
|
||||
let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}`
|
||||
if (newRec.freq === "WEEKLY" && newRec.byDay?.length) {
|
||||
rrule += `;BYDAY=${newRec.byDay.join(",")}`
|
||||
}
|
||||
if (newRec.count) rrule += `;COUNT=${newRec.count}`
|
||||
if (newRec.until) rrule += `;UNTIL=${newRec.until.replace(/-/g, "")}T000000Z`
|
||||
// Build RRULE string
|
||||
let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}`;
|
||||
if (newRec.freq === "WEEKLY" && newRec.byDay?.length) {
|
||||
rrule += `;BYDAY=${newRec.byDay.join(",")}`;
|
||||
}
|
||||
if (newRec.count) rrule += `;COUNT=${newRec.count}`;
|
||||
if (newRec.until)
|
||||
rrule += `;UNTIL=${newRec.until.replace(/-/g, "")}T000000Z`;
|
||||
|
||||
onChange(rrule)
|
||||
}
|
||||
onChange(rrule);
|
||||
};
|
||||
|
||||
const toggleDay = (day: string) => {
|
||||
const byDay = rec.byDay || []
|
||||
const newByDay = byDay.includes(day) ? byDay.filter((d) => d !== day) : [...byDay, day]
|
||||
update({ byDay: newByDay })
|
||||
}
|
||||
const toggleDay = (day: string) => {
|
||||
const byDay = rec.byDay || [];
|
||||
const newByDay = byDay.includes(day)
|
||||
? byDay.filter((d) => d !== day)
|
||||
: [...byDay, day];
|
||||
update({ byDay: newByDay });
|
||||
};
|
||||
|
||||
const dayLabels = {
|
||||
MO: "Mon",
|
||||
TU: "Tue",
|
||||
WE: "Wed",
|
||||
TH: "Thu",
|
||||
FR: "Fri",
|
||||
SA: "Sat",
|
||||
SU: "Sun",
|
||||
}
|
||||
const dayLabels = {
|
||||
MO: "Mon",
|
||||
TU: "Tue",
|
||||
WE: "Wed",
|
||||
TH: "Thu",
|
||||
FR: "Fri",
|
||||
SA: "Sat",
|
||||
SU: "Sun",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<Label htmlFor="frequency" className="pt-4 pb-2 pl-1">Repeats</Label>
|
||||
<div className="space-y-2">
|
||||
<Select value={rec.freq} onValueChange={(value) => update({ freq: value as Recurrence["freq"] })}>
|
||||
<SelectTrigger id="frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<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>
|
||||
return (
|
||||
<div className="">
|
||||
<Label htmlFor="frequency" className="pt-4 pb-2 pl-1">
|
||||
Repeats
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={rec.freq}
|
||||
onValueChange={(value) =>
|
||||
update({ freq: value as Recurrence["freq"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<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" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="interval">
|
||||
Interval (every {rec.interval} {rec.freq === "DAILY" ? "day" : rec.freq === "WEEKLY" ? "week" : "month"}
|
||||
{rec.interval > 1 ? "s" : ""})
|
||||
</Label>
|
||||
<Input
|
||||
id="interval"
|
||||
type="number"
|
||||
min={1}
|
||||
value={rec.interval}
|
||||
onChange={(e) => update({ interval: Number.parseInt(e.target.value, 10) || 1 })}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
{rec.freq !== "NONE" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="interval">
|
||||
Interval (every {rec.interval}{" "}
|
||||
{rec.freq === "DAILY"
|
||||
? "day"
|
||||
: rec.freq === "WEEKLY"
|
||||
? "week"
|
||||
: "month"}
|
||||
{rec.interval > 1 ? "s" : ""})
|
||||
</Label>
|
||||
<Input
|
||||
id="interval"
|
||||
type="number"
|
||||
min={1}
|
||||
value={rec.interval}
|
||||
onChange={(e) =>
|
||||
update({ interval: Number.parseInt(e.target.value, 10) || 1 })
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{rec.freq === "WEEKLY" && (
|
||||
<div className="space-y-2">
|
||||
<Label>Days of the week</Label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((day) => (
|
||||
<div key={day} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={day}
|
||||
checked={rec.byDay?.includes(day) || false}
|
||||
onCheckedChange={() => toggleDay(day)}
|
||||
/>
|
||||
<Label htmlFor={day} className="text-sm font-normal">
|
||||
{dayLabels[day as keyof typeof dayLabels]}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{rec.freq === "WEEKLY" && (
|
||||
<div className="space-y-2">
|
||||
<Label>Days of the week</Label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((day) => (
|
||||
<div key={day} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={day}
|
||||
checked={rec.byDay?.includes(day) || false}
|
||||
onCheckedChange={() => toggleDay(day)}
|
||||
/>
|
||||
<Label htmlFor={day} className="text-sm font-normal">
|
||||
{dayLabels[day as keyof typeof dayLabels]}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="count">End after (occurrences)</Label>
|
||||
<Input
|
||||
id="count"
|
||||
type="number"
|
||||
placeholder="e.g. 10"
|
||||
value={rec.count || ""}
|
||||
onChange={(e) => update({ count: e.target.value ? Number.parseInt(e.target.value, 10) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="until">End by date</Label>
|
||||
<Input
|
||||
id="until"
|
||||
type="date"
|
||||
value={rec.until || ""}
|
||||
onChange={(e) => update({ until: e.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="count">End after (occurrences)</Label>
|
||||
<Input
|
||||
id="count"
|
||||
type="number"
|
||||
placeholder="e.g. 10"
|
||||
value={rec.count || ""}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
count: e.target.value
|
||||
? Number.parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="until">End by date</Label>
|
||||
<Input
|
||||
id="until"
|
||||
type="date"
|
||||
value={rec.until || ""}
|
||||
onChange={(e) => update({ until: e.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,244 +1,306 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import type { RecurrenceRule } from "@/lib/rfc5545-types"
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { RecurrenceRule } from "@/lib/rfc5545-types";
|
||||
|
||||
interface RRuleDisplayProps {
|
||||
rrule: string | RecurrenceRule
|
||||
className?: string
|
||||
rrule: string | RecurrenceRule;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RRuleDisplay({ rrule, className }: RRuleDisplayProps) {
|
||||
const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule
|
||||
const humanText = formatRRuleToHuman(parsedRule)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<span className="text-sm text-muted-foreground">{humanText}</span>
|
||||
</div>
|
||||
)
|
||||
const parsedRule =
|
||||
typeof rrule === "string" ? parseRRuleString(rrule) : rrule;
|
||||
const humanText = formatRRuleToHuman(parsedRule);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<span className="text-sm text-muted-foreground">{humanText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RRuleDisplayDetailedProps {
|
||||
rrule: string | RecurrenceRule
|
||||
className?: string
|
||||
showBadges?: boolean
|
||||
rrule: string | RecurrenceRule;
|
||||
className?: string;
|
||||
showBadges?: boolean;
|
||||
}
|
||||
|
||||
export function RRuleDisplayDetailed({ rrule, className, showBadges = true }: RRuleDisplayDetailedProps) {
|
||||
const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule
|
||||
const humanText = formatRRuleToHuman(parsedRule)
|
||||
const details = getRRuleDetails(parsedRule)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{humanText}</div>
|
||||
|
||||
{showBadges && details.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{details.map((detail, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{detail}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
export function RRuleDisplayDetailed({
|
||||
rrule,
|
||||
className,
|
||||
showBadges = true,
|
||||
}: RRuleDisplayDetailedProps) {
|
||||
const parsedRule =
|
||||
typeof rrule === "string" ? parseRRuleString(rrule) : rrule;
|
||||
const humanText = formatRRuleToHuman(parsedRule);
|
||||
const details = getRRuleDetails(parsedRule);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{humanText}</div>
|
||||
|
||||
{showBadges && details.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{details.map((detail, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{detail}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseRRuleString(rruleString: string): RecurrenceRule {
|
||||
const parts = Object.fromEntries(rruleString.split(";").map(p => p.split("=")))
|
||||
|
||||
return {
|
||||
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,
|
||||
count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined,
|
||||
interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined,
|
||||
bySecond: parts.BYSECOND ? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10)) : 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'],
|
||||
}
|
||||
const parts = Object.fromEntries(
|
||||
rruleString.split(";").map((p) => p.split("=")),
|
||||
);
|
||||
|
||||
return {
|
||||
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,
|
||||
count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined,
|
||||
interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined,
|
||||
bySecond: parts.BYSECOND
|
||||
? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10))
|
||||
: 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 {
|
||||
const { freq, interval = 1, count, until, byDay, byMonthDay, byMonth, byHour, byMinute, bySecond } = rule
|
||||
|
||||
let text = ""
|
||||
|
||||
// Base frequency
|
||||
switch (freq) {
|
||||
case 'SECONDLY':
|
||||
text = interval === 1 ? "Every second" : `Every ${interval} seconds`
|
||||
break
|
||||
case 'MINUTELY':
|
||||
text = interval === 1 ? "Every minute" : `Every ${interval} minutes`
|
||||
break
|
||||
case 'HOURLY':
|
||||
text = interval === 1 ? "Every hour" : `Every ${interval} hours`
|
||||
break
|
||||
case 'DAILY':
|
||||
text = interval === 1 ? "Daily" : `Every ${interval} days`
|
||||
break
|
||||
case 'WEEKLY':
|
||||
text = interval === 1 ? "Weekly" : `Every ${interval} weeks`
|
||||
break
|
||||
case 'MONTHLY':
|
||||
text = interval === 1 ? "Monthly" : `Every ${interval} months`
|
||||
break
|
||||
case 'YEARLY':
|
||||
text = interval === 1 ? "Yearly" : `Every ${interval} years`
|
||||
break
|
||||
}
|
||||
|
||||
// Add day specifications
|
||||
if (byDay?.length) {
|
||||
const dayNames = {
|
||||
'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday',
|
||||
'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday'
|
||||
}
|
||||
|
||||
const days = byDay.map(day => {
|
||||
// Handle numbered days like "2TU" (second Tuesday)
|
||||
const match = day.match(/^(-?\d+)?([A-Z]{2})$/)
|
||||
if (match) {
|
||||
const [, num, dayCode] = match
|
||||
const dayName = dayNames[dayCode as keyof typeof dayNames]
|
||||
if (num) {
|
||||
const ordinal = getOrdinal(parseInt(num))
|
||||
return `${ordinal} ${dayName}`
|
||||
}
|
||||
return dayName
|
||||
}
|
||||
return day
|
||||
})
|
||||
|
||||
if (freq === 'WEEKLY') {
|
||||
text += ` on ${formatList(days)}`
|
||||
} else {
|
||||
text += ` on ${formatList(days)}`
|
||||
}
|
||||
}
|
||||
|
||||
// Add month day specifications
|
||||
if (byMonthDay?.length) {
|
||||
const days = byMonthDay.map(day => {
|
||||
if (day < 0) {
|
||||
return `${getOrdinal(Math.abs(day))} to last day`
|
||||
}
|
||||
return getOrdinal(day)
|
||||
})
|
||||
text += ` on the ${formatList(days)}`
|
||||
}
|
||||
|
||||
// Add month specifications
|
||||
if (byMonth?.length) {
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
]
|
||||
const months = byMonth.map(month => monthNames[month - 1])
|
||||
text += ` in ${formatList(months)}`
|
||||
}
|
||||
|
||||
// Add time specifications
|
||||
if (byHour?.length || byMinute?.length || bySecond?.length) {
|
||||
const timeSpecs = []
|
||||
if (byHour?.length) {
|
||||
const hours = byHour.map(h => `${h.toString().padStart(2, '0')}:00`)
|
||||
timeSpecs.push(`at ${formatList(hours)}`)
|
||||
}
|
||||
if (byMinute?.length && !byHour?.length) {
|
||||
timeSpecs.push(`at minute ${formatList(byMinute.map(String))}`)
|
||||
}
|
||||
if (bySecond?.length && !byHour?.length && !byMinute?.length) {
|
||||
timeSpecs.push(`at second ${formatList(bySecond.map(String))}`)
|
||||
}
|
||||
if (timeSpecs.length) {
|
||||
text += ` ${timeSpecs.join(' ')}`
|
||||
}
|
||||
}
|
||||
|
||||
// Add end conditions
|
||||
if (count) {
|
||||
text += `, ${count} time${count === 1 ? '' : 's'}`
|
||||
} else if (until) {
|
||||
const date = new Date(until)
|
||||
text += `, until ${date.toLocaleDateString()}`
|
||||
}
|
||||
|
||||
return text
|
||||
const {
|
||||
freq,
|
||||
interval = 1,
|
||||
count,
|
||||
until,
|
||||
byDay,
|
||||
byMonthDay,
|
||||
byMonth,
|
||||
byHour,
|
||||
byMinute,
|
||||
bySecond,
|
||||
} = rule;
|
||||
|
||||
let text = "";
|
||||
|
||||
// Base frequency
|
||||
switch (freq) {
|
||||
case "SECONDLY":
|
||||
text = interval === 1 ? "Every second" : `Every ${interval} seconds`;
|
||||
break;
|
||||
case "MINUTELY":
|
||||
text = interval === 1 ? "Every minute" : `Every ${interval} minutes`;
|
||||
break;
|
||||
case "HOURLY":
|
||||
text = interval === 1 ? "Every hour" : `Every ${interval} hours`;
|
||||
break;
|
||||
case "DAILY":
|
||||
text = interval === 1 ? "Daily" : `Every ${interval} days`;
|
||||
break;
|
||||
case "WEEKLY":
|
||||
text = interval === 1 ? "Weekly" : `Every ${interval} weeks`;
|
||||
break;
|
||||
case "MONTHLY":
|
||||
text = interval === 1 ? "Monthly" : `Every ${interval} months`;
|
||||
break;
|
||||
case "YEARLY":
|
||||
text = interval === 1 ? "Yearly" : `Every ${interval} years`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Add day specifications
|
||||
if (byDay?.length) {
|
||||
const dayNames = {
|
||||
SU: "Sunday",
|
||||
MO: "Monday",
|
||||
TU: "Tuesday",
|
||||
WE: "Wednesday",
|
||||
TH: "Thursday",
|
||||
FR: "Friday",
|
||||
SA: "Saturday",
|
||||
};
|
||||
|
||||
const days = byDay.map((day) => {
|
||||
// Handle numbered days like "2TU" (second Tuesday)
|
||||
const match = day.match(/^(-?\d+)?([A-Z]{2})$/);
|
||||
if (match) {
|
||||
const [, num, dayCode] = match;
|
||||
const dayName = dayNames[dayCode as keyof typeof dayNames];
|
||||
if (num) {
|
||||
const ordinal = getOrdinal(parseInt(num));
|
||||
return `${ordinal} ${dayName}`;
|
||||
}
|
||||
return dayName;
|
||||
}
|
||||
return day;
|
||||
});
|
||||
|
||||
if (freq === "WEEKLY") {
|
||||
text += ` on ${formatList(days)}`;
|
||||
} else {
|
||||
text += ` on ${formatList(days)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add month day specifications
|
||||
if (byMonthDay?.length) {
|
||||
const days = byMonthDay.map((day) => {
|
||||
if (day < 0) {
|
||||
return `${getOrdinal(Math.abs(day))} to last day`;
|
||||
}
|
||||
return getOrdinal(day);
|
||||
});
|
||||
text += ` on the ${formatList(days)}`;
|
||||
}
|
||||
|
||||
// Add month specifications
|
||||
if (byMonth?.length) {
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
const months = byMonth.map((month) => monthNames[month - 1]);
|
||||
text += ` in ${formatList(months)}`;
|
||||
}
|
||||
|
||||
// Add time specifications
|
||||
if (byHour?.length || byMinute?.length || bySecond?.length) {
|
||||
const timeSpecs = [];
|
||||
if (byHour?.length) {
|
||||
const hours = byHour.map((h) => `${h.toString().padStart(2, "0")}:00`);
|
||||
timeSpecs.push(`at ${formatList(hours)}`);
|
||||
}
|
||||
if (byMinute?.length && !byHour?.length) {
|
||||
timeSpecs.push(`at minute ${formatList(byMinute.map(String))}`);
|
||||
}
|
||||
if (bySecond?.length && !byHour?.length && !byMinute?.length) {
|
||||
timeSpecs.push(`at second ${formatList(bySecond.map(String))}`);
|
||||
}
|
||||
if (timeSpecs.length) {
|
||||
text += ` ${timeSpecs.join(" ")}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add end conditions
|
||||
if (count) {
|
||||
text += `, ${count} time${count === 1 ? "" : "s"}`;
|
||||
} else if (until) {
|
||||
const date = new Date(until);
|
||||
text += `, until ${date.toLocaleDateString()}`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function getRRuleDetails(rule: RecurrenceRule): string[] {
|
||||
const details: string[] = []
|
||||
|
||||
if (rule.wkst && rule.wkst !== 'MO') {
|
||||
const dayNames = {
|
||||
'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday',
|
||||
'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday'
|
||||
}
|
||||
details.push(`Week starts ${dayNames[rule.wkst]}`)
|
||||
}
|
||||
|
||||
if (rule.byWeekNo?.length) {
|
||||
details.push(`Week ${formatList(rule.byWeekNo.map(String))}`)
|
||||
}
|
||||
|
||||
if (rule.byYearDay?.length) {
|
||||
details.push(`Day ${formatList(rule.byYearDay.map(String))} of year`)
|
||||
}
|
||||
|
||||
if (rule.bySetPos?.length) {
|
||||
const positions = rule.bySetPos.map(pos => {
|
||||
if (pos < 0) {
|
||||
return `${getOrdinal(Math.abs(pos))} to last`
|
||||
}
|
||||
return getOrdinal(pos)
|
||||
})
|
||||
details.push(`Position ${formatList(positions)}`)
|
||||
}
|
||||
|
||||
return details
|
||||
const details: string[] = [];
|
||||
|
||||
if (rule.wkst && rule.wkst !== "MO") {
|
||||
const dayNames = {
|
||||
SU: "Sunday",
|
||||
MO: "Monday",
|
||||
TU: "Tuesday",
|
||||
WE: "Wednesday",
|
||||
TH: "Thursday",
|
||||
FR: "Friday",
|
||||
SA: "Saturday",
|
||||
};
|
||||
details.push(`Week starts ${dayNames[rule.wkst]}`);
|
||||
}
|
||||
|
||||
if (rule.byWeekNo?.length) {
|
||||
details.push(`Week ${formatList(rule.byWeekNo.map(String))}`);
|
||||
}
|
||||
|
||||
if (rule.byYearDay?.length) {
|
||||
details.push(`Day ${formatList(rule.byYearDay.map(String))} of year`);
|
||||
}
|
||||
|
||||
if (rule.bySetPos?.length) {
|
||||
const positions = rule.bySetPos.map((pos) => {
|
||||
if (pos < 0) {
|
||||
return `${getOrdinal(Math.abs(pos))} to last`;
|
||||
}
|
||||
return getOrdinal(pos);
|
||||
});
|
||||
details.push(`Position ${formatList(positions)}`);
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
function getOrdinal(num: number): string {
|
||||
const suffix = ['th', 'st', 'nd', 'rd']
|
||||
const v = num % 100
|
||||
return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0])
|
||||
const suffix = ["th", "st", "nd", "rd"];
|
||||
const v = num % 100;
|
||||
return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]);
|
||||
}
|
||||
|
||||
function formatList(items: string[]): string {
|
||||
if (items.length === 0) return ''
|
||||
if (items.length === 1) return items[0]
|
||||
if (items.length === 2) return `${items[0]} and ${items[1]}`
|
||||
return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`
|
||||
if (items.length === 0) return "";
|
||||
if (items.length === 1) return items[0];
|
||||
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
||||
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
// Hook for easy usage in components
|
||||
export function useRRuleDisplay(rrule?: string) {
|
||||
if (!rrule) return null
|
||||
|
||||
try {
|
||||
const parsedRule = parseRRuleString(rrule)
|
||||
return {
|
||||
humanText: formatRRuleToHuman(parsedRule),
|
||||
details: getRRuleDetails(parsedRule),
|
||||
parsedRule
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
humanText: "Invalid recurrence rule",
|
||||
details: [],
|
||||
parsedRule: null,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
if (!rrule) return null;
|
||||
|
||||
try {
|
||||
const parsedRule = parseRRuleString(rrule);
|
||||
return {
|
||||
humanText: formatRRuleToHuman(parsedRule),
|
||||
details: getRRuleDetails(parsedRule),
|
||||
parsedRule,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
humanText: "Invalid recurrence rule",
|
||||
details: [],
|
||||
parsedRule: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { signOut, useSession } from "@/lib/auth-client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { signOut, useSession } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SignIn() {
|
||||
const { data: session, isPending } = useSession()
|
||||
const router = useRouter()
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut()
|
||||
router.push("/")
|
||||
} catch (_error) {
|
||||
toast.error("Failed to sign out. Please try again.")
|
||||
}
|
||||
}
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
} catch (_error) {
|
||||
toast.error("Failed to sign out. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>
|
||||
}
|
||||
if (isPending) {
|
||||
return <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>;
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={handleSignOut} variant="ghost" size="default">
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (session?.user) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={handleSignOut} variant="ghost" size="default">
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => router.push("/auth/signin")}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
)
|
||||
return (
|
||||
<Button
|
||||
onClick={() => router.push("/auth/signin")}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user