Custom date-picker.tsx with date/datetime modes, portal popup with month/year dropdowns, min/max constraints, and hidden input for form validation. Replaces all 10 native <input type="date"> and <input type="datetime-local"> across LockScreen, SettingsPage, PersonForm, TodoForm, TodoDetailPanel, TaskForm, TaskDetailPanel, ProjectForm, ReminderForm, and ReminderDetailPanel. Adds Chromium calendar icon invert CSS fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
475 lines
16 KiB
TypeScript
475 lines
16 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { format, parseISO, isPast, isToday } from 'date-fns';
|
|
import {
|
|
X, Pencil, Trash2, Save, Bell, BellOff, Clock, Repeat, AlertCircle, AlignLeft,
|
|
} from 'lucide-react';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import type { Reminder } from '@/types';
|
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
|
import CopyableField from '@/components/shared/CopyableField';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Select } from '@/components/ui/select';
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
// --- Types ---
|
|
|
|
interface ReminderDetailPanelProps {
|
|
reminder: Reminder | null;
|
|
isCreating?: boolean;
|
|
onClose: () => void;
|
|
onSaved?: () => void;
|
|
onDeleted?: () => void;
|
|
}
|
|
|
|
interface EditState {
|
|
title: string;
|
|
description: string;
|
|
remind_at: string;
|
|
recurrence_rule: string;
|
|
}
|
|
|
|
const recurrenceLabels: Record<string, string> = {
|
|
daily: 'Daily',
|
|
weekly: 'Weekly',
|
|
monthly: 'Monthly',
|
|
};
|
|
|
|
const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const;
|
|
|
|
function buildEditState(reminder: Reminder): EditState {
|
|
return {
|
|
title: reminder.title,
|
|
description: reminder.description || '',
|
|
remind_at: reminder.remind_at ? reminder.remind_at.slice(0, 16) : '',
|
|
recurrence_rule: reminder.recurrence_rule || '',
|
|
};
|
|
}
|
|
|
|
function buildCreateState(): EditState {
|
|
return {
|
|
title: '',
|
|
description: '',
|
|
remind_at: '',
|
|
recurrence_rule: '',
|
|
};
|
|
}
|
|
|
|
// --- Component ---
|
|
|
|
export default function ReminderDetailPanel({
|
|
reminder,
|
|
isCreating = false,
|
|
onClose,
|
|
onSaved,
|
|
onDeleted,
|
|
}: ReminderDetailPanelProps) {
|
|
const queryClient = useQueryClient();
|
|
|
|
const [isEditing, setIsEditing] = useState(isCreating);
|
|
const [editState, setEditState] = useState<EditState>(() =>
|
|
isCreating ? buildCreateState() : reminder ? buildEditState(reminder) : buildCreateState()
|
|
);
|
|
|
|
// Reset state when reminder changes
|
|
useEffect(() => {
|
|
setIsEditing(false);
|
|
if (reminder) setEditState(buildEditState(reminder));
|
|
}, [reminder?.id]);
|
|
|
|
// Enter edit mode when creating
|
|
useEffect(() => {
|
|
if (isCreating) {
|
|
setIsEditing(true);
|
|
setEditState(buildCreateState());
|
|
}
|
|
}, [isCreating]);
|
|
|
|
const invalidateAll = useCallback(() => {
|
|
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
|
|
}, [queryClient]);
|
|
|
|
// --- Mutations ---
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async (data: EditState) => {
|
|
const payload = {
|
|
title: data.title,
|
|
description: data.description || null,
|
|
remind_at: data.remind_at || null,
|
|
recurrence_rule: data.recurrence_rule || null,
|
|
};
|
|
if (reminder && !isCreating) {
|
|
return api.put(`/reminders/${reminder.id}`, payload);
|
|
} else {
|
|
return api.post('/reminders', payload);
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
toast.success(isCreating ? 'Reminder created' : 'Reminder updated');
|
|
if (isCreating) {
|
|
onClose();
|
|
} else {
|
|
setIsEditing(false);
|
|
}
|
|
onSaved?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, isCreating ? 'Failed to create reminder' : 'Failed to update reminder'));
|
|
},
|
|
});
|
|
|
|
const dismissMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const { data } = await api.patch(`/reminders/${reminder!.id}/dismiss`);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
toast.success('Reminder dismissed');
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to dismiss reminder');
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async () => {
|
|
await api.delete(`/reminders/${reminder!.id}`);
|
|
},
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
toast.success('Reminder deleted');
|
|
onClose();
|
|
onDeleted?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to delete reminder'));
|
|
},
|
|
});
|
|
|
|
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
|
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
|
|
|
|
// --- Handlers ---
|
|
|
|
const handleEditStart = () => {
|
|
if (reminder) setEditState(buildEditState(reminder));
|
|
setIsEditing(true);
|
|
};
|
|
|
|
const handleEditCancel = () => {
|
|
setIsEditing(false);
|
|
if (isCreating) {
|
|
onClose();
|
|
} else if (reminder) {
|
|
setEditState(buildEditState(reminder));
|
|
}
|
|
};
|
|
|
|
const handleEditSave = () => {
|
|
saveMutation.mutate(editState);
|
|
};
|
|
|
|
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
|
|
setEditState((s) => ({ ...s, [key]: value }));
|
|
};
|
|
|
|
// Empty state
|
|
if (!reminder && !isCreating) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
<Bell className="h-8 w-8 mb-3 opacity-40" />
|
|
<p className="text-sm">Select a reminder to view details</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// View data
|
|
const remindDate = reminder?.remind_at ? parseISO(reminder.remind_at) : null;
|
|
const isOverdue = !reminder?.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate);
|
|
const isDueToday = remindDate ? isToday(remindDate) : false;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
|
<div className="flex items-start justify-between gap-3">
|
|
{isEditing && !isCreating ? (
|
|
<Input
|
|
value={editState.title}
|
|
onChange={(e) => updateField('title', e.target.value)}
|
|
className="h-8 text-base font-semibold flex-1"
|
|
placeholder="Reminder title"
|
|
autoFocus
|
|
/>
|
|
) : isCreating ? (
|
|
<h3 className="font-heading text-lg font-semibold">New Reminder</h3>
|
|
) : (
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<Bell
|
|
className={`h-4 w-4 shrink-0 ${
|
|
isOverdue ? 'text-red-400' : reminder!.is_dismissed ? 'text-muted-foreground' : 'text-orange-400'
|
|
}`}
|
|
/>
|
|
<h3 className={`font-heading text-lg font-semibold truncate ${reminder!.is_dismissed ? 'line-through text-muted-foreground' : ''}`}>
|
|
{reminder!.title}
|
|
</h3>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{(isEditing || isCreating) ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-green-400 hover:text-green-300"
|
|
onClick={handleEditSave}
|
|
disabled={saveMutation.isPending}
|
|
title="Save"
|
|
>
|
|
<Save className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleEditCancel}
|
|
title="Cancel"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
{!reminder!.is_dismissed && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
|
|
onClick={() => dismissMutation.mutate()}
|
|
disabled={dismissMutation.isPending}
|
|
title="Dismiss reminder"
|
|
>
|
|
<BellOff className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleEditStart}
|
|
title="Edit reminder"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
{confirmingDelete ? (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleDeleteClick}
|
|
disabled={deleteMutation.isPending}
|
|
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
|
title="Confirm delete"
|
|
>
|
|
Sure?
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
onClick={handleDeleteClick}
|
|
disabled={deleteMutation.isPending}
|
|
title="Delete reminder"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={onClose}
|
|
title="Close panel"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
|
{(isEditing || isCreating) ? (
|
|
/* Edit / Create mode */
|
|
<div className="space-y-4">
|
|
{isCreating && (
|
|
<div className="space-y-1">
|
|
<Label htmlFor="reminder-title" required>Title</Label>
|
|
<Input
|
|
id="reminder-title"
|
|
value={editState.title}
|
|
onChange={(e) => updateField('title', e.target.value)}
|
|
placeholder="Reminder title"
|
|
required
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="reminder-desc">Description</Label>
|
|
<Textarea
|
|
id="reminder-desc"
|
|
value={editState.description}
|
|
onChange={(e) => updateField('description', e.target.value)}
|
|
placeholder="Add a description..."
|
|
rows={3}
|
|
className="text-sm resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="reminder-at">Remind At</Label>
|
|
<DatePicker
|
|
id="reminder-at"
|
|
mode="datetime"
|
|
value={editState.remind_at}
|
|
onChange={(v) => updateField('remind_at', v)}
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="reminder-recurrence">Recurrence</Label>
|
|
<Select
|
|
id="reminder-recurrence"
|
|
value={editState.recurrence_rule}
|
|
onChange={(e) => updateField('recurrence_rule', e.target.value)}
|
|
className="text-xs"
|
|
>
|
|
<option value="">None</option>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
<option value="monthly">Monthly</option>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save / Cancel at bottom */}
|
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
<Button variant="outline" size="sm" onClick={handleEditCancel}>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
|
|
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* View mode */
|
|
<>
|
|
{/* 2-column grid: Status, Recurrence, Remind At, Snoozed Until */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Status */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
{reminder!.is_dismissed ? (
|
|
<BellOff className="h-3 w-3" />
|
|
) : isOverdue ? (
|
|
<AlertCircle className="h-3 w-3" />
|
|
) : (
|
|
<Bell className="h-3 w-3" />
|
|
)}
|
|
Status
|
|
</div>
|
|
{reminder!.is_dismissed ? (
|
|
<p className="text-sm text-muted-foreground">Dismissed</p>
|
|
) : isOverdue ? (
|
|
<p className="text-sm text-red-400">Overdue</p>
|
|
) : isDueToday ? (
|
|
<p className="text-sm text-yellow-400">Due today</p>
|
|
) : (
|
|
<p className="text-sm text-orange-400">Active</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recurrence */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Repeat className="h-3 w-3" />
|
|
Recurrence
|
|
</div>
|
|
{reminder!.recurrence_rule ? (
|
|
<p className="text-sm">{recurrenceLabels[reminder!.recurrence_rule] || reminder!.recurrence_rule}</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Remind At */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Clock className="h-3 w-3" />
|
|
Remind At
|
|
</div>
|
|
{remindDate ? (
|
|
<CopyableField
|
|
value={format(remindDate, 'EEEE, MMMM d, yyyy · h:mm a')}
|
|
icon={Clock}
|
|
label="Remind at"
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Snoozed Until */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Clock className="h-3 w-3" />
|
|
Snoozed Until
|
|
</div>
|
|
{reminder!.snoozed_until ? (
|
|
<p className="text-sm">{format(parseISO(reminder!.snoozed_until), 'MMM d, h:mm a')}</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description — full width */}
|
|
{reminder!.description && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<AlignLeft className="h-3 w-3" />
|
|
Description
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
|
|
{reminder!.description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Updated at */}
|
|
<div className="pt-2 border-t border-border">
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{formatUpdatedAt(reminder!.updated_at)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|