Merge security/pentest-remediation-2026-02-26: remediate F-01, F-02, F-06

This commit is contained in:
Kyle 2026-02-26 02:43:11 +08:00
commit 132226120b
28 changed files with 2784 additions and 536 deletions

View File

@ -16,5 +16,17 @@ SECRET_KEY=change-this-to-a-random-secret-key-in-production
# Timezone (applied to backend + db containers via env_file)
TZ=Australia/Perth
# Session cookie security
# Set to true when serving over HTTPS. Required before any TLS deployment.
# COOKIE_SECURE=true
# Integrations
OPENWEATHERMAP_API_KEY=your-openweathermap-api-key
# Production security checklist (enable all before any non-internal deployment):
# 1. Set SECRET_KEY to output of: openssl rand -hex 32
# 2. Set POSTGRES_PASSWORD to a strong unique value
# 3. Set ENVIRONMENT=production (disables Swagger/ReDoc on backend:8000)
# 4. Set COOKIE_SECURE=true (requires TLS termination at nginx or upstream)
# 5. Add HSTS to nginx.conf: add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# 6. Complete user_id migration (migration 026) before enabling multi-user accounts

View File

@ -111,8 +111,8 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
- **Non-root containers** — both backend (`appuser:1000`) and frontend (`nginx-unprivileged`) run as non-root
- **No external backend port** — port 8000 is internal-only; all traffic flows through nginx
- **Server version suppression**`server_tokens off` (nginx) and `--no-server-header` (uvicorn)
- **Rate limiting** — nginx `limit_req_zone` (10 req/min, burst=5) on `/api/auth/login`, `/verify-password`, `/change-password`, `/totp-verify`
- **Application-level rate limiting** — in-memory IP-based rate limit (5 attempts / 5 min) + DB-backed account lockout (10 failures = 30-min lock)
- **Rate limiting** — nginx `limit_req_zone` (10 req/min) on `/api/auth/login` (burst=5), `/verify-password` (burst=5), `/change-password` (burst=5), `/totp-verify` (burst=5), `/setup` (burst=3)
- **DB-backed account lockout** — 10 failed attempts triggers 30-minute lock per account
- **Dotfile blocking**`/.env`, `/.git/config`, etc. return 404 (`.well-known` preserved for ACME)
- **CSP headers** — Content-Security-Policy on all responses, scoped for Google Fonts
- **CORS** — configurable origins with explicit method/header allowlists

View File

@ -9,14 +9,12 @@ Session flow:
GET /status verify user exists + session valid
Security layers:
1. IP-based in-memory rate limit (5 attempts / 5 min) outer guard, username enumeration
1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) outer guard on all auth endpoints
2. DB-backed account lockout (10 failures 30-min lock, HTTP 423) per-user guard
3. Session revocation stored in DB (survives container restarts)
4. bcryptArgon2id transparent upgrade on first login with migrated hash
"""
import uuid
import time
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Optional
@ -40,41 +38,6 @@ from app.config import settings as app_settings
router = APIRouter()
# ---------------------------------------------------------------------------
# IP-based in-memory rate limit (retained as outer layer for all login attempts)
# ---------------------------------------------------------------------------
_failed_attempts: dict[str, list[float]] = defaultdict(list)
_MAX_IP_ATTEMPTS = 5
_IP_WINDOW_SECONDS = 300 # 5 minutes
_MAX_TRACKED_IPS = 10000 # cap to prevent unbounded memory growth
def _check_ip_rate_limit(ip: str) -> None:
"""Raise 429 if the IP has exceeded the failure window."""
now = time.time()
# Purge all stale entries if the dict is oversized (spray attack defense)
if len(_failed_attempts) > _MAX_TRACKED_IPS:
stale_ips = [k for k, v in _failed_attempts.items() if all(now - t >= _IP_WINDOW_SECONDS for t in v)]
for k in stale_ips:
del _failed_attempts[k]
# If still over cap after purge, clear everything (all entries are within window
# but we can't let memory grow unbounded — login will still hit account lockout)
if len(_failed_attempts) > _MAX_TRACKED_IPS:
_failed_attempts.clear()
_failed_attempts[ip] = [t for t in _failed_attempts[ip] if now - t < _IP_WINDOW_SECONDS]
if not _failed_attempts[ip]:
_failed_attempts.pop(ip, None)
elif len(_failed_attempts[ip]) >= _MAX_IP_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many failed login attempts. Try again in a few minutes.",
)
def _record_ip_failure(ip: str) -> None:
_failed_attempts[ip].append(time.time())
# ---------------------------------------------------------------------------
# Cookie helper
# ---------------------------------------------------------------------------
@ -267,14 +230,12 @@ async def login(
HTTP 429 IP rate limited
"""
client_ip = request.client.host if request.client else "unknown"
_check_ip_rate_limit(client_ip)
# Lookup user — do NOT differentiate "user not found" from "wrong password"
result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none()
if not user:
_record_ip_failure(client_ip)
raise HTTPException(status_code=401, detail="Invalid username or password")
await _check_account_lockout(user)
@ -283,7 +244,6 @@ async def login(
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
if not valid:
_record_ip_failure(client_ip)
await _record_failed_login(db, user)
raise HTTPException(status_code=401, detail="Invalid username or password")
@ -291,8 +251,6 @@ async def login(
if new_hash:
user.password_hash = new_hash
# Clear IP failures and update user state
_failed_attempts.pop(client_ip, None)
await _record_successful_login(db, user)
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
@ -376,7 +334,6 @@ async def auth_status(
@router.post("/verify-password")
async def verify_password(
data: VerifyPasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -384,20 +341,15 @@ async def verify_password(
Verify the current user's password without changing anything.
Used by the frontend lock screen to re-authenticate without a full login.
Also handles transparent bcryptArgon2id upgrade.
Shares the same rate-limit and lockout guards as /login.
Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting.
"""
client_ip = request.client.host if request.client else "unknown"
_check_ip_rate_limit(client_ip)
await _check_account_lockout(current_user)
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
if not valid:
_record_ip_failure(client_ip)
await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid password")
_failed_attempts.pop(client_ip, None)
# Persist upgraded hash if migration happened
if new_hash:
current_user.password_hash = new_hash
@ -409,22 +361,17 @@ async def verify_password(
@router.post("/change-password")
async def change_password(
data: ChangePasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Change the current user's password. Requires old password verification."""
client_ip = request.client.host if request.client else "unknown"
_check_ip_rate_limit(client_ip)
await _check_account_lockout(current_user)
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid:
_record_ip_failure(client_ip)
await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid current password")
_failed_attempts.pop(client_ip, None)
current_user.password_hash = hash_password(data.new_password)
await db.commit()

View File

@ -17,13 +17,18 @@ logger = logging.getLogger(__name__)
NTFY_TIMEOUT = 8.0 # seconds — hard cap to prevent hung requests
# Loopback + link-local only. Private IPs (RFC 1918) are intentionally allowed
# because UMBRA is self-hosted and the user's ntfy server is typically on the same LAN.
# Loopback, link-local, and all RFC 1918 private ranges are blocked to prevent
# SSRF against Docker-internal services. If a self-hosted ntfy server on the LAN
# is required, remove the RFC 1918 entries from _BLOCKED_NETWORKS and document the accepted risk.
_BLOCKED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fe80::/10"),
ipaddress.ip_network("127.0.0.0/8"), # IPv4 loopback
ipaddress.ip_network("10.0.0.0/8"), # RFC 1918 private
ipaddress.ip_network("172.16.0.0/12"), # RFC 1918 private — covers Docker bridge 172.17-31.x
ipaddress.ip_network("192.168.0.0/16"), # RFC 1918 private
ipaddress.ip_network("169.254.0.0/16"), # IPv4 link-local
ipaddress.ip_network("::1/128"), # IPv6 loopback
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
ipaddress.ip_network("fc00::/7"), # IPv6 ULA (covers fd00::/8)
]

View File

@ -46,6 +46,13 @@ server {
include /etc/nginx/proxy-params.conf;
}
location /api/auth/setup {
# Tighter burst: setup is one-time-only, 3 attempts is sufficient
limit_req zone=auth_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# API proxy
location /api {
proxy_pass http://backend:8000;

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import FullCalendar from '@fullcalendar/react';
@ -6,20 +7,16 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, EventTemplate } from '@/types';
import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import CalendarSidebar from './CalendarSidebar';
import EventForm from './EventForm';
import EventDetailPanel from './EventDetailPanel';
import type { CreateDefaults } from './EventDetailPanel';
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
@ -29,32 +26,57 @@ const viewLabels: Record<CalendarView, string> = {
timeGridDay: 'Day',
};
type ScopeAction = 'edit' | 'delete';
export default function CalendarPage() {
const queryClient = useQueryClient();
const location = useLocation();
const calendarRef = useRef<FullCalendar>(null);
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
const [selectedStart, setSelectedStart] = useState<string | null>(null);
const [selectedEnd, setSelectedEnd] = useState<string | null>(null);
const [selectedAllDay, setSelectedAllDay] = useState(false);
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
const [calendarTitle, setCalendarTitle] = useState('');
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null);
const [templateName, setTemplateName] = useState<string | null>(null);
const [eventSearch, setEventSearch] = useState('');
const [searchFocused, setSearchFocused] = useState(false);
// Scope dialog state
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null);
const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null);
// Panel state
const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
const { settings } = useSettings();
const { data: calendars = [] } = useCalendars();
const calendarContainerRef = useRef<HTMLDivElement>(null);
// Location data for event panel
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<LocationType[]>('/locations');
return data;
},
staleTime: 5 * 60 * 1000,
});
const locationMap = useMemo(() => {
const map = new Map<number, string>();
locations.forEach((l) => map.set(l.id, l.name));
return map;
}, [locations]);
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { date?: string; view?: string; eventId?: number } | null;
if (!state) return;
const calApi = calendarRef.current?.getApi();
if (state.date && calApi) {
calApi.gotoDate(state.date);
if (state.view) calApi.changeView(state.view);
}
if (state.eventId) {
setSelectedEventId(state.eventId);
setPanelMode('view');
}
window.history.replaceState({}, '');
}, [location.state]);
// Resize FullCalendar when container size changes (e.g. sidebar collapse)
useEffect(() => {
const el = calendarContainerRef.current;
@ -66,6 +88,23 @@ export default function CalendarPage() {
return () => observer.disconnect();
}, []);
const panelOpen = panelMode !== 'closed';
// Continuously resize calendar during panel open/close CSS transition
useEffect(() => {
let rafId: number;
const start = performance.now();
const duration = 350; // slightly longer than the 300ms CSS transition
const tick = () => {
calendarRef.current?.getApi().updateSize();
if (performance.now() - start < duration) {
rafId = requestAnimationFrame(tick);
}
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [panelOpen]);
// Scroll wheel navigation in month view
useEffect(() => {
const el = calendarContainerRef.current;
@ -94,6 +133,21 @@ export default function CalendarPage() {
},
});
const selectedEvent = useMemo(
() => events.find((e) => e.id === selectedEventId) ?? null,
[selectedEventId, events],
);
// Escape key closes detail panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
const visibleCalendarIds = useMemo(
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
[calendars],
@ -152,28 +206,38 @@ export default function CalendarPage() {
},
});
const scopeDeleteMutation = useMutation({
mutationFn: async ({ id, scope }: { id: number; scope: string }) => {
await api.delete(`/events/${id}?scope=${scope}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Event(s) deleted');
setScopeDialogOpen(false);
setScopeEvent(null);
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const filteredEvents = useMemo(() => {
if (calendars.length === 0) return events;
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
}, [events, visibleCalendarIds, calendars.length]);
const searchResults = useMemo(() => {
if (!eventSearch.trim()) return [];
const q = eventSearch.toLowerCase();
return filteredEvents
.filter((e) => e.title.toLowerCase().includes(q))
.slice(0, 8);
}, [filteredEvents, eventSearch]);
const handleSearchSelect = (event: CalendarEvent) => {
const calApi = calendarRef.current?.getApi();
if (!calApi) return;
const startDate = new Date(event.start_datetime);
calApi.gotoDate(startDate);
if (event.all_day) {
calApi.changeView('dayGridMonth');
} else {
calApi.changeView('timeGridDay');
}
setEventSearch('');
setSearchFocused(false);
// Also open the event in the panel
if (!event.is_virtual) {
setSelectedEventId(event.id);
setPanelMode('view');
}
};
const calendarEvents = filteredEvents.map((event) => ({
id: String(event.id),
title: event.title,
@ -189,10 +253,6 @@ export default function CalendarPage() {
},
}));
const isRecurring = (event: CalendarEvent): boolean => {
return !!(event.is_recurring || event.parent_event_id);
};
const handleEventClick = (info: EventClickArg) => {
const event = events.find((e) => String(e.id) === info.event.id);
if (!event) return;
@ -200,27 +260,8 @@ export default function CalendarPage() {
toast.info(`${event.title} — from People contacts`);
return;
}
if (isRecurring(event)) {
setScopeEvent(event);
setScopeAction('edit');
setScopeDialogOpen(true);
} else {
setEditingEvent(event);
setShowForm(true);
}
};
const handleScopeChoice = (scope: 'this' | 'this_and_future') => {
if (!scopeEvent) return;
if (scopeAction === 'edit') {
setEditingEvent(scopeEvent);
setActiveEditScope(scope);
setShowForm(true);
setScopeDialogOpen(false);
} else if (scopeAction === 'delete') {
scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope });
}
setSelectedEventId(event.id);
setPanelMode('view');
};
const handleEventDrop = (info: EventDropArg) => {
@ -228,7 +269,6 @@ export default function CalendarPage() {
info.revert();
return;
}
// Prevent drag-drop on recurring events — user must use scope dialog via click
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
info.revert();
toast.info('Click the event to edit recurring events');
@ -253,7 +293,6 @@ export default function CalendarPage() {
info.revert();
return;
}
// Prevent resize on recurring events — user must use scope dialog via click
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
info.revert();
toast.info('Click the event to edit recurring events');
@ -274,26 +313,33 @@ export default function CalendarPage() {
};
const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedStart(selectInfo.startStr);
setSelectedEnd(selectInfo.endStr);
setSelectedAllDay(selectInfo.allDay);
setShowForm(true);
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults({
start: selectInfo.startStr,
end: selectInfo.endStr,
allDay: selectInfo.allDay,
});
};
const handleCloseForm = () => {
const handleCreateNew = () => {
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults(null);
};
const handlePanelClose = () => {
calendarRef.current?.getApi().unselect();
setShowForm(false);
setEditingEvent(null);
setTemplateEvent(null);
setTemplateName(null);
setActiveEditScope(null);
setSelectedStart(null);
setSelectedEnd(null);
setSelectedAllDay(false);
setPanelMode('closed');
setSelectedEventId(null);
setCreateDefaults(null);
};
const handleUseTemplate = (template: EventTemplate) => {
setTemplateEvent({
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults({
templateData: {
title: template.title,
description: template.description || '',
all_day: template.all_day,
@ -301,10 +347,9 @@ export default function CalendarPage() {
location_id: template.location_id || undefined,
is_starred: template.is_starred,
recurrence_rule: template.recurrence_rule || undefined,
} as Partial<CalendarEvent>);
setTemplateName(template.name);
setEditingEvent(null);
setShowForm(true);
} as Partial<CalendarEvent>,
templateName: template.name,
});
};
const handleDatesSet = (arg: DatesSetArg) => {
@ -318,11 +363,11 @@ export default function CalendarPage() {
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
return (
<div className="flex h-full overflow-hidden">
<div className="flex h-full overflow-hidden animate-fade-in">
<CalendarSidebar onUseTemplate={handleUseTemplate} />
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar — h-16 matches sidebar header */}
{/* Custom toolbar */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
@ -335,7 +380,7 @@ export default function CalendarPage() {
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
Today
</Button>
<h2 className="text-lg font-semibold font-heading flex-1">{calendarTitle}</h2>
<div className="flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<button
@ -355,10 +400,61 @@ export default function CalendarPage() {
</button>
))}
</div>
<h2 className="text-lg font-semibold font-heading">{calendarTitle}</h2>
<div className="flex-1" />
{/* Event search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search events..."
value={eventSearch}
onChange={(e) => setEventSearch(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
className="w-52 h-8 pl-8 text-sm ring-inset"
/>
{searchFocused && searchResults.length > 0 && (
<div className="absolute z-50 mt-1 w-72 right-0 rounded-md border bg-popover shadow-lg overflow-hidden">
{searchResults.map((event) => (
<button
key={event.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSearchSelect(event)}
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
/>
<div className="min-w-0 flex-1">
<span className="font-medium truncate block">{event.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(event.start_datetime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</div>
</button>
))}
</div>
)}
</div>
{/* Calendar grid */}
<div className="flex-1 overflow-y-auto">
<Button size="sm" onClick={handleCreateNew}>
<Plus className="mr-2 h-4 w-4" />
Create Event
</Button>
</div>
{/* Calendar grid + event detail panel */}
<div className="flex-1 overflow-hidden flex">
<div
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="h-full">
<FullCalendar
key={`fc-${settings?.first_day_of_week ?? 0}`}
@ -384,60 +480,46 @@ export default function CalendarPage() {
/>
</div>
</div>
</div>
{showForm && (
<EventForm
event={editingEvent}
templateData={templateEvent}
templateName={templateName}
initialStart={selectedStart}
initialEnd={selectedEnd}
initialAllDay={selectedAllDay}
editScope={activeEditScope}
onClose={handleCloseForm}
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<EventDetailPanel
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
createDefaults={createDefaults}
onClose={handlePanelClose}
onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
/>
)}
{/* Recurring event scope dialog */}
<Dialog open={scopeDialogOpen} onOpenChange={setScopeDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
This is a recurring event. How would you like to proceed?
</p>
<div className="flex flex-col gap-2 mt-2">
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeChoice('this')}
>
This event only
</Button>
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeChoice('this_and_future')}
>
This and all future events
</Button>
<Button
variant="ghost"
className="w-full justify-center"
onClick={() => {
setScopeDialogOpen(false);
setScopeEvent(null);
}}
>
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<EventDetailPanel
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
createDefaults={createDefaults}
onClose={handlePanelClose}
onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,915 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO } from 'date-fns';
import {
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
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 { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import LocationPicker from '@/components/ui/location-picker';
// --- Helpers ---
function toDateOnly(dt: string): string {
if (!dt) return '';
return dt.split('T')[0];
}
function toDatetimeLocal(dt: string, fallbackTime = '09:00'): string {
if (!dt) return '';
if (dt.includes('T')) return dt.slice(0, 16);
return `${dt}T${fallbackTime}`;
}
function formatForInput(dt: string, allDay: boolean, fallbackTime = '09:00'): string {
if (!dt) return '';
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
}
function adjustAllDayEndForDisplay(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr.split('T')[0] + 'T12:00:00');
d.setDate(d.getDate() - 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function adjustAllDayEndForSave(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr + 'T12:00:00');
d.setDate(d.getDate() + 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function nowLocal(): string {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
function plusOneHour(dt: string): string {
const d = new Date(dt);
d.setHours(d.getHours() + 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatRecurrenceRule(rule: string): string {
try {
const parsed = JSON.parse(rule);
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
switch (parsed.type) {
case 'every_n_days':
return parsed.interval === 1 ? 'Every day' : `Every ${parsed.interval} days`;
case 'weekly':
return parsed.weekday != null ? `Weekly on ${weekdays[parsed.weekday]}` : 'Weekly';
case 'monthly_nth_weekday':
return parsed.week && parsed.weekday != null
? `Monthly on week ${parsed.week}, ${weekdays[parsed.weekday]}`
: 'Monthly';
case 'monthly_date':
return parsed.day ? `Monthly on the ${parsed.day}${ordinal(parsed.day)}` : 'Monthly';
default:
return 'Recurring';
}
} catch {
return 'Recurring';
}
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
// Python weekday: 0=Monday, 6=Sunday
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
// --- Types ---
export interface CreateDefaults {
start?: string;
end?: string;
allDay?: boolean;
templateData?: Partial<CalendarEvent>;
templateName?: string;
}
interface EventDetailPanelProps {
event: CalendarEvent | null;
isCreating?: boolean;
createDefaults?: CreateDefaults | null;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
locationName?: string;
}
interface EditState {
title: string;
description: string;
start_datetime: string;
end_datetime: string;
all_day: boolean;
location_id: string;
calendar_id: string;
is_starred: boolean;
recurrence_type: string;
recurrence_interval: number;
recurrence_weekday: number;
recurrence_week: number;
recurrence_day: number;
}
function buildEditStateFromEvent(event: CalendarEvent): EditState {
const rule = parseRecurrenceRule(event.recurrence_rule);
const isAllDay = event.all_day;
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(event.end_datetime) : event.end_datetime;
return {
title: event.title,
description: event.description || '',
start_datetime: formatForInput(event.start_datetime, isAllDay, '09:00'),
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: event.location_id?.toString() || '',
calendar_id: event.calendar_id?.toString() || '',
is_starred: event.is_starred || false,
recurrence_type: rule?.type || '',
recurrence_interval: rule?.interval || 2,
recurrence_weekday: rule?.weekday ?? 1,
recurrence_week: rule?.week || 1,
recurrence_day: rule?.day || 1,
};
}
function buildCreateState(defaults: CreateDefaults | null, defaultCalendarId: string): EditState {
const source = defaults?.templateData;
const isAllDay = source?.all_day ?? defaults?.allDay ?? false;
const defaultStart = nowLocal();
const defaultEnd = plusOneHour(defaultStart);
const rawStart = defaults?.start || defaultStart;
const rawEnd = defaults?.end || defaultEnd;
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd;
const rule = parseRecurrenceRule(source?.recurrence_rule);
return {
title: source?.title || '',
description: source?.description || '',
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: source?.location_id?.toString() || '',
calendar_id: source?.calendar_id?.toString() || defaultCalendarId,
is_starred: source?.is_starred || false,
recurrence_type: rule?.type || '',
recurrence_interval: rule?.interval || 2,
recurrence_weekday: rule?.weekday ?? 1,
recurrence_week: rule?.week || 1,
recurrence_day: rule?.day || 1,
};
}
function buildRecurrencePayload(state: EditState): RecurrenceRule | null {
if (!state.recurrence_type) return null;
switch (state.recurrence_type) {
case 'every_n_days':
return { type: 'every_n_days', interval: state.recurrence_interval };
case 'weekly':
return { type: 'weekly' };
case 'monthly_nth_weekday':
return { type: 'monthly_nth_weekday', week: state.recurrence_week, weekday: state.recurrence_weekday };
case 'monthly_date':
return { type: 'monthly_date', day: state.recurrence_day };
default:
return null;
}
}
// --- Component ---
export default function EventDetailPanel({
event,
isCreating = false,
createDefaults,
onClose,
onSaved,
onDeleted,
locationName,
}: EventDetailPanelProps) {
const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars();
const selectableCalendars = calendars.filter((c) => !c.is_system);
const defaultCalendar = calendars.find((c) => c.is_default);
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<LocationType[]>('/locations');
return data;
},
staleTime: 5 * 60 * 1000,
});
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState<EditState>(() =>
isCreating
? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')
: event
? buildEditStateFromEvent(event)
: buildCreateState(null, defaultCalendar?.id?.toString() || '')
);
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
const [locationSearch, setLocationSearch] = useState('');
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
// Reset state when event changes
useEffect(() => {
setIsEditing(false);
setScopeStep(null);
setEditScope(null);
setLocationSearch('');
if (event) setEditState(buildEditStateFromEvent(event));
}, [event?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || ''));
setLocationSearch('');
}
}, [isCreating, createDefaults]);
// Initialize location search text from existing location
useEffect(() => {
if (isEditing && !isCreating && event?.location_id) {
const loc = locations.find((l) => l.id === event.location_id);
if (loc) setLocationSearch(loc.name);
}
}, [isEditing, isCreating, event?.location_id, locations]);
const invalidateAll = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
}, [queryClient]);
// --- Mutations ---
const saveMutation = useMutation({
mutationFn: async (data: EditState) => {
const rule = buildRecurrencePayload(data);
let endDt = data.end_datetime;
if (data.all_day && endDt) endDt = adjustAllDayEndForSave(endDt);
const payload: Record<string, unknown> = {
title: data.title,
description: data.description || null,
start_datetime: data.start_datetime,
end_datetime: endDt,
all_day: data.all_day,
location_id: data.location_id ? parseInt(data.location_id) : null,
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
is_starred: data.is_starred,
recurrence_rule: rule,
};
if (event && !isCreating) {
if (editScope) payload.edit_scope = editScope;
return api.put(`/events/${event.id}`, payload);
} else {
return api.post('/events', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Event created' : 'Event updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
setEditScope(null);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event'));
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
const scope = editScope ? `?scope=${editScope}` : '';
await api.delete(`/events/${event!.id}${scope}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Event deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (isRecurring) {
setScopeStep('edit');
} else {
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
}
};
const handleScopeSelect = (scope: 'this' | 'this_and_future') => {
setEditScope(scope);
if (scopeStep === 'edit') {
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
setScopeStep(null);
} else if (scopeStep === 'delete') {
// Delete with scope — execute immediately
setScopeStep(null);
// The deleteMutation will read editScope, but we need to set it first
// Since setState is async, use the mutation directly with the scope
const scopeParam = `?scope=${scope}`;
api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
invalidateAll();
toast.success('Event(s) deleted');
onClose();
onDeleted?.();
}).catch((error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
});
}
};
const handleEditCancel = () => {
setIsEditing(false);
setEditScope(null);
setLocationSearch('');
if (isCreating) {
onClose();
} else if (event) {
setEditState(buildEditStateFromEvent(event));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const handleDeleteStart = () => {
if (isRecurring) {
setScopeStep('delete');
} else {
handleDeleteClick();
}
};
// --- Render helpers ---
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!event && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Calendar className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select an event to view details</p>
</div>
);
}
// View mode data
const startDate = event ? parseISO(event.start_datetime) : null;
const endDate = event?.end_datetime ? parseISO(event.end_datetime) : null;
const startStr = startDate
? event!.all_day
? format(startDate, 'EEEE, MMMM d, yyyy')
: format(startDate, 'EEEE, MMMM d, yyyy · h:mm a')
: '';
const endStr = endDate
? event!.all_day
? format(endDate, 'EEEE, MMMM d, yyyy')
: format(endDate, 'h:mm a')
: null;
const panelTitle = isCreating
? createDefaults?.templateName
? `New Event from ${createDefaults.templateName}`
: 'New Event'
: event?.title || '';
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 ? (
<div className="flex-1 min-w-0">
{isCreating ? (
<h3 className="font-heading text-lg font-semibold">{panelTitle}</h3>
) : (
<Input
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
className="h-8 text-base font-semibold"
placeholder="Event title"
autoFocus
/>
)}
</div>
) : scopeStep ? (
<h3 className="font-heading text-sm font-semibold">
{scopeStep === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{event?.title}</h3>
<span className="text-xs text-muted-foreground">{event?.calendar_name}</span>
</div>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{scopeStep ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setScopeStep(null)}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
) : isEditing ? (
<>
<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>
</>
) : (
<>
{!event?.is_virtual && (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit event"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteStart}
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={handleDeleteStart}
disabled={deleteMutation.isPending}
title="Delete event"
>
<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">
{scopeStep ? (
/* Scope selection step */
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
This is a recurring event. How would you like to proceed?
</p>
<div className="flex flex-col gap-2">
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeSelect('this')}
>
This event only
</Button>
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeSelect('this_and_future')}
>
This and all future events
</Button>
<Button
variant="ghost"
className="w-full justify-center"
onClick={() => setScopeStep(null)}
>
Cancel
</Button>
</div>
</div>
) : isEditing ? (
/* Edit / Create mode */
<div className="space-y-4">
{/* Title (only shown in body for create mode; edit mode has it in header) */}
{isCreating && (
<div className="space-y-1">
<Label htmlFor="panel-title" required>Title</Label>
<Input
id="panel-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Event title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="panel-desc">Description</Label>
<Textarea
id="panel-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="flex items-center gap-2">
<Checkbox
id="panel-allday"
checked={editState.all_day}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
updateField('all_day', checked);
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00'));
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
}}
/>
<Label htmlFor="panel-allday">All day event</Label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-start" required>Start</Label>
<Input
id="panel-start"
type={editState.all_day ? 'date' : 'datetime-local'}
value={editState.start_datetime}
onChange={(e) => updateField('start_datetime', e.target.value)}
className="text-xs"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="panel-end">End</Label>
<Input
id="panel-end"
type={editState.all_day ? 'date' : 'datetime-local'}
value={editState.end_datetime}
onChange={(e) => updateField('end_datetime', e.target.value)}
className="text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-calendar">Calendar</Label>
<Select
id="panel-calendar"
value={editState.calendar_id}
onChange={(e) => updateField('calendar_id', e.target.value)}
className="text-xs"
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="panel-location">Location</Label>
<LocationPicker
id="panel-location"
value={locationSearch}
onChange={(val) => {
setLocationSearch(val);
if (!val) updateField('location_id', '');
}}
onSelect={async (result) => {
if (result.source === 'local' && result.location_id) {
updateField('location_id', result.location_id.toString());
} else if (result.source === 'nominatim') {
try {
const { data: newLoc } = await api.post('/locations', {
name: result.name,
address: result.address,
category: 'other',
});
queryClient.invalidateQueries({ queryKey: ['locations'] });
updateField('location_id', newLoc.id.toString());
toast.success(`Location "${result.name}" created`);
} catch {
toast.error('Failed to create location');
}
}
}}
placeholder="Search location..."
/>
</div>
</div>
{/* Recurrence */}
<div className="space-y-1">
<Label htmlFor="panel-recurrence">Recurrence</Label>
<Select
id="panel-recurrence"
value={editState.recurrence_type}
onChange={(e) => updateField('recurrence_type', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="every_n_days">Every X days</option>
<option value="weekly">Weekly</option>
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
<option value="monthly_date">Monthly (date)</option>
</Select>
</div>
{editState.recurrence_type === 'every_n_days' && (
<div className="space-y-1">
<Label htmlFor="panel-interval">Every how many days?</Label>
<Input
id="panel-interval"
type="number"
min={1}
max={365}
value={editState.recurrence_interval}
onChange={(e) => updateField('recurrence_interval', parseInt(e.target.value) || 1)}
className="text-xs"
/>
</div>
)}
{editState.recurrence_type === 'weekly' && (
<p className="text-xs text-muted-foreground">
Repeats every week on the same day as the start date.
</p>
)}
{editState.recurrence_type === 'monthly_nth_weekday' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-week">Week of month</Label>
<Select
id="panel-week"
value={editState.recurrence_week.toString()}
onChange={(e) => updateField('recurrence_week', parseInt(e.target.value))}
className="text-xs"
>
<option value="1">1st</option>
<option value="2">2nd</option>
<option value="3">3rd</option>
<option value="4">4th</option>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="panel-weekday">Day of week</Label>
<Select
id="panel-weekday"
value={editState.recurrence_weekday.toString()}
onChange={(e) => updateField('recurrence_weekday', parseInt(e.target.value))}
className="text-xs"
>
{WEEKDAYS.map((name, i) => (
<option key={i} value={i}>{name}</option>
))}
</Select>
</div>
</div>
)}
{editState.recurrence_type === 'monthly_date' && (
<div className="space-y-1">
<Label htmlFor="panel-day">Day of month</Label>
<Input
id="panel-day"
type="number"
min={1}
max={31}
value={editState.recurrence_day}
onChange={(e) => updateField('recurrence_day', parseInt(e.target.value) || 1)}
className="text-xs"
/>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="panel-starred"
checked={editState.is_starred}
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
/>
<Label htmlFor="panel-starred">Star this event</Label>
</div>
{/* Save / Cancel buttons at bottom of form */}
<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: Calendar, Starred, Start, End, Location, Recurrence */}
<div className="grid grid-cols-2 gap-3">
{/* Calendar */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Calendar className="h-3 w-3" />
Calendar
</div>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<span className="text-sm">{event?.calendar_name}</span>
</div>
</div>
{/* Starred */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Star className="h-3 w-3" />
Starred
</div>
{event?.is_starred ? (
<p className="text-sm text-amber-200/90">Starred</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Start */}
<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" />
Start
</div>
<CopyableField value={startStr} icon={Clock} label="Start time" />
</div>
{/* End */}
<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" />
End
</div>
{endStr ? (
<CopyableField value={endStr} icon={Clock} label="End time" />
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Location */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<MapPin className="h-3 w-3" />
Location
</div>
{locationName ? (
<CopyableField value={locationName} icon={MapPin} label="Location" />
) : (
<p className="text-sm text-muted-foreground"></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>
{isRecurring && event?.recurrence_rule ? (
<p className="text-sm">{formatRecurrenceRule(event.recurrence_rule)}</p>
) : isRecurring ? (
<p className="text-sm">Recurring event</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* Description — full width */}
{event?.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">{event.description}</p>
</div>
)}
{/* Updated at */}
{event && !event.is_virtual && (
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(event.updated_at)}
</span>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Calendar } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -17,6 +18,9 @@ interface CalendarWidgetProps {
}
export default function CalendarWidget({ events }: CalendarWidgetProps) {
const navigate = useNavigate();
const todayStr = format(new Date(), 'yyyy-MM-dd');
return (
<Card>
<CardHeader>
@ -37,7 +41,8 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
{events.map((event) => (
<div
key={event.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
>
<div
className="w-1.5 h-1.5 rounded-full shrink-0"

View File

@ -1,4 +1,5 @@
import { differenceInCalendarDays } from 'date-fns';
import { differenceInCalendarDays, format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Star } from 'lucide-react';
interface CountdownWidgetProps {
@ -10,6 +11,7 @@ interface CountdownWidgetProps {
}
export default function CountdownWidget({ events }: CountdownWidgetProps) {
const navigate = useNavigate();
const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0);
if (visible.length === 0) return null;
@ -18,8 +20,13 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) {
{visible.map((event) => {
const days = differenceInCalendarDays(new Date(event.start_datetime), new Date());
const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`;
const dateStr = format(new Date(event.start_datetime), 'yyyy-MM-dd');
return (
<div key={event.id} className="flex items-center gap-2">
<div
key={event.id}
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: event.id } })}
className="flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1 -mx-1 transition-colors duration-150"
>
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
<span className="text-sm text-amber-200/90 truncate">
<span className="font-semibold tabular-nums">{label}</span>

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react';
@ -33,6 +34,7 @@ function getGreeting(name?: string): string {
}
export default function DashboardPage() {
const navigate = useNavigate();
const { settings } = useSettings();
const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts();
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
@ -234,7 +236,8 @@ export default function DashboardPage() {
{futureReminders.map((reminder) => (
<div
key={reminder.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
onClick={() => navigate('/reminders', { state: { reminderId: reminder.id } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
>
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
<span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>

View File

@ -1,3 +1,4 @@
import { useNavigate } from 'react-router-dom';
import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
@ -11,6 +12,8 @@ interface StatsWidgetProps {
}
export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) {
const navigate = useNavigate();
const statCards = [
{
label: 'PROJECTS',
@ -18,6 +21,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
icon: FolderKanban,
color: 'text-blue-400',
glowBg: 'bg-blue-500/10',
onClick: () => navigate('/projects'),
},
{
label: 'IN PROGRESS',
@ -25,6 +29,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
icon: TrendingUp,
color: 'text-purple-400',
glowBg: 'bg-purple-500/10',
onClick: () => navigate('/projects', { state: { filter: 'in_progress' } }),
},
{
label: 'OPEN TODOS',
@ -32,13 +37,18 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
icon: CheckSquare,
color: 'text-teal-400',
glowBg: 'bg-teal-500/10',
onClick: () => navigate('/todos'),
},
];
return (
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
{statCards.map((stat) => (
<Card key={stat.label} className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<Card
key={stat.label}
className="bg-gradient-to-br from-accent/[0.03] to-transparent cursor-pointer group transition-transform duration-150 hover:scale-[1.01]"
onClick={stat.onClick}
>
<CardContent className="px-3 py-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">

View File

@ -1,4 +1,5 @@
import { format, isPast, endOfDay } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { CheckCircle2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@ -29,6 +30,8 @@ const dotColors: Record<string, string> = {
};
export default function TodoWidget({ todos }: TodoWidgetProps) {
const navigate = useNavigate();
return (
<Card>
<CardHeader>
@ -51,7 +54,8 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
return (
<div
key={todo.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
onClick={() => navigate('/todos', { state: { todoId: todo.id } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
>
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />
<span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span>

View File

@ -1,4 +1,5 @@
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react';
import type { UpcomingItem } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -17,6 +18,26 @@ const typeConfig: Record<string, { icon: typeof CheckSquare; color: string; labe
};
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
const navigate = useNavigate();
const handleItemClick = (item: UpcomingItem) => {
switch (item.type) {
case 'event': {
const dateStr = item.datetime
? format(new Date(item.datetime), 'yyyy-MM-dd')
: item.date;
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: item.id } });
break;
}
case 'todo':
navigate('/todos', { state: { todoId: item.id } });
break;
case 'reminder':
navigate('/reminders', { state: { reminderId: item.id } });
break;
}
};
return (
<Card className="h-full">
<CardHeader>
@ -44,7 +65,8 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
return (
<div
key={`${item.type}-${item.id}-${index}`}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
onClick={() => handleItemClick(item)}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
>
<Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} />
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns';
import type { UpcomingItem } from '@/types';
import { cn } from '@/lib/utils';
@ -14,6 +15,7 @@ const typeColors: Record<string, string> = {
};
export default function WeekTimeline({ items }: WeekTimelineProps) {
const navigate = useNavigate();
const today = useMemo(() => startOfDay(new Date()), []);
const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]);
@ -41,12 +43,13 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
{days.map((day) => (
<div
key={day.key}
onClick={() => navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })}
className={cn(
'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border',
'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border cursor-pointer',
day.isToday
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
: day.isPast
? 'border-transparent opacity-50'
? 'border-transparent opacity-50 hover:opacity-75'
: 'border-transparent hover:border-border/50'
)}
>

View File

@ -10,7 +10,10 @@ import LockOverlay from './LockOverlay';
export default function AppLayout() {
useTheme();
const [collapsed, setCollapsed] = useState(false);
const [collapsed, setCollapsed] = useState(() => {
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
catch { return false; }
});
const [mobileOpen, setMobileOpen] = useState(false);
return (
@ -19,7 +22,11 @@ export default function AppLayout() {
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
@ -20,6 +20,7 @@ import {
import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { useLock } from '@/hooks/useLock';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { Button } from '@/components/ui/button';
import api from '@/lib/api';
import type { Project } from '@/types';
@ -57,10 +58,12 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
select: (data) => data.map(({ id, name }) => ({ id, name })),
});
const handleLogout = async () => {
const doLogout = useCallback(async () => {
await logout();
navigate('/login');
};
}, [logout, navigate]);
const { confirming: logoutConfirming, handleClick: handleLogout } = useConfirmAction(doLogout);
const isProjectsActive = location.pathname.startsWith('/projects');
const showExpanded = !collapsed || mobileOpen;
@ -200,10 +203,15 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
</NavLink>
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
logoutConfirming
? 'bg-destructive/15 text-destructive'
: 'text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
)}
>
<LogOut className="h-5 w-5 shrink-0" />
{showExpanded && <span>Logout</span>}
{showExpanded && <span>{logoutConfirming ? 'Sure?' : 'Logout'}</span>}
</button>
</div>
</>

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { Plus, MapPin, Phone, Mail } from 'lucide-react';
import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
@ -240,11 +240,11 @@ export default function LocationsPage() {
];
const panelFields: PanelField[] = [
{ label: 'Address', key: 'address', copyable: true, icon: MapPin },
{ label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
{ label: 'Category', key: 'category' },
{ label: 'Notes', key: 'notes', multiline: true },
{ label: 'Category', key: 'category', icon: Tag },
{ label: 'Address', key: 'address', copyable: true, icon: MapPin, fullWidth: true },
{ label: 'Notes', key: 'notes', multiline: true, icon: AlignLeft, fullWidth: true },
];
const renderPanel = () => (
@ -283,7 +283,7 @@ export default function LocationsPage() {
);
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin } from 'lucide-react';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format, parseISO, differenceInYears } from 'date-fns';
@ -173,12 +173,12 @@ const panelFields: PanelField[] = [
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
{ label: 'Address', key: 'address', copyable: true, icon: MapPin },
{ label: 'Birthday', key: 'birthday_display' },
{ label: 'Category', key: 'category' },
{ label: 'Company', key: 'company' },
{ label: 'Job Title', key: 'job_title' },
{ label: 'Notes', key: 'notes', multiline: true },
{ label: 'Birthday', key: 'birthday_display', icon: Cake },
{ label: 'Category', key: 'category', icon: Tag },
{ label: 'Company', key: 'company', icon: Building2 },
{ label: 'Job Title', key: 'job_title', icon: Briefcase },
{ label: 'Address', key: 'address', copyable: true, icon: MapPin, fullWidth: true },
{ label: 'Notes', key: 'notes', multiline: true, icon: AlignLeft, fullWidth: true },
];
// ---------------------------------------------------------------------------
@ -237,6 +237,9 @@ export default function PeoplePage() {
list = list.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.first_name?.toLowerCase().includes(q) ||
p.last_name?.toLowerCase().includes(q) ||
p.nickname?.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.mobile?.toLowerCase().includes(q) ||
p.phone?.toLowerCase().includes(q) ||
@ -400,10 +403,11 @@ export default function PeoplePage() {
);
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
<div className="flex-1 min-w-0">
<CategoryFilterBar
activeFilters={activeFilters}
pinnedLabel="Favourites"
@ -417,7 +421,7 @@ export default function PeoplePage() {
searchValue={search}
onSearchChange={setSearch}
/>
<div className="flex-1" />
</div>
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
<Plus className="mr-2 h-4 w-4" />
Add Person

View File

@ -344,7 +344,7 @@ export default function ProjectDetail() {
if (isLoading) {
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full animate-fade-in">
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
@ -373,7 +373,7 @@ export default function ProjectDetail() {
project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date));
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>

View File

@ -1,9 +1,11 @@
import { useState } from 'react';
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2 } from 'lucide-react';
import { useState, useMemo, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Project } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
@ -18,9 +20,20 @@ const statusFilters = [
] as const;
export default function ProjectsPage() {
const location = useLocation();
const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [statusFilter, setStatusFilter] = useState('');
const [search, setSearch] = useState('');
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { filter?: string } | null;
if (state?.filter) {
setStatusFilter(state.filter);
window.history.replaceState({}, '');
}
}, [location.state]);
const { data: projects = [], isLoading } = useQuery({
queryKey: ['projects'],
@ -30,9 +43,16 @@ export default function ProjectsPage() {
},
});
const filteredProjects = statusFilter
? projects.filter((p) => p.status === statusFilter)
: projects;
const filteredProjects = useMemo(() => {
let list = statusFilter ? projects.filter((p) => p.status === statusFilter) : projects;
if (search) {
const q = search.toLowerCase();
list = list.filter(
(p) => p.name.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q)
);
}
return list;
}, [projects, statusFilter, search]);
const inProgressCount = projects.filter((p) => p.status === 'in_progress').length;
const completedCount = projects.filter((p) => p.status === 'completed').length;
@ -48,7 +68,7 @@ export default function ProjectsPage() {
};
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1>
@ -75,6 +95,16 @@ export default function ProjectsPage() {
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm"
/>
</div>
<Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
New Project

View File

@ -7,6 +7,7 @@ import {
Calendar, User, Flag, Activity, Send, X, Save,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import { formatUpdatedAt } from '@/components/shared/utils';
import type { ProjectTask, TaskComment, Person } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -558,6 +559,15 @@ export default function TaskDetailPanel({
<Send className="h-4 w-4" />
</Button>
</div>
{/* Updated at footer */}
{task.updated_at && (
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(task.updated_at)}
</span>
</div>
)}
</div>
</div>
</div>

View File

@ -0,0 +1,473 @@
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 { 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(false);
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 ? (
<>
<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 ? (
/* 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>
<Input
id="reminder-at"
type="datetime-local"
value={editState.remind_at}
onChange={(e) => updateField('remind_at', e.target.value)}
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>
);
}

View File

@ -1,4 +1,5 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { isPast, isToday, parseISO } from 'date-fns';
@ -9,7 +10,7 @@ import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton';
import ReminderList from './ReminderList';
import ReminderForm from './ReminderForm';
import ReminderDetailPanel from './ReminderDetailPanel';
const statusFilters = [
{ value: 'active', label: 'Active' },
@ -20,11 +21,25 @@ const statusFilters = [
type StatusFilter = (typeof statusFilters)[number]['value'];
export default function RemindersPage() {
const [showForm, setShowForm] = useState(false);
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
const location = useLocation();
// Panel state
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
const [filter, setFilter] = useState<StatusFilter>('active');
const [search, setSearch] = useState('');
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { reminderId?: number } | null;
if (state?.reminderId) {
setSelectedReminderId(state.reminderId);
setPanelMode('view');
window.history.replaceState({}, '');
}
}, [location.state]);
const { data: reminders = [], isLoading } = useQuery({
queryKey: ['reminders'],
queryFn: async () => {
@ -50,18 +65,39 @@ export default function RemindersPage() {
).length;
const dismissedCount = reminders.filter((r) => r.is_dismissed).length;
const handleEdit = (reminder: Reminder) => {
setEditingReminder(reminder);
setShowForm(true);
const panelOpen = panelMode !== 'closed';
const selectedReminder = useMemo(
() => reminders.find((r) => r.id === selectedReminderId) ?? null,
[selectedReminderId, reminders],
);
const handleSelect = (reminder: Reminder) => {
setSelectedReminderId(reminder.id);
setPanelMode('view');
};
const handleCloseForm = () => {
setShowForm(false);
setEditingReminder(null);
const handleCreateNew = () => {
setSelectedReminderId(null);
setPanelMode('create');
};
const handlePanelClose = () => {
setPanelMode('closed');
setSelectedReminderId(null);
};
// Escape key closes panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1>
@ -87,25 +123,32 @@ export default function RemindersPage() {
))}
</div>
<div className="relative ml-2">
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm"
className="w-52 h-8 pl-8 text-sm ring-inset"
/>
</div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm">
<Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Reminder
</Button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Main content — list + detail panel */}
<div className="flex-1 overflow-hidden flex">
<div
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="px-6 py-5">
{/* Summary stats */}
{!isLoading && reminders.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5">
@ -156,13 +199,47 @@ export default function RemindersPage() {
) : (
<ReminderList
reminders={filteredReminders}
onEdit={handleEdit}
onAdd={() => setShowForm(true)}
onEdit={handleSelect}
onAdd={handleCreateNew}
/>
)}
</div>
</div>
{showForm && <ReminderForm reminder={editingReminder} onClose={handleCloseForm} />}
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<ReminderDetailPanel
reminder={panelMode === 'view' ? selectedReminder : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<ReminderDetailPanel
reminder={panelMode === 'view' ? selectedReminder : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -210,7 +210,7 @@ export default function SettingsPage() {
};
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full animate-fade-in">
{/* Page header — matches Stage 4-5 pages */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0">
<Settings className="h-5 w-5 text-accent" aria-hidden="true" />

View File

@ -118,7 +118,6 @@ export default function CategoryFilterBar({
onSearchChange,
}: CategoryFilterBarProps) {
const [otherOpen, setOtherOpen] = useState(false);
const [searchCollapsed, setSearchCollapsed] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const isAllActive = activeFilters.length === 0;
@ -129,16 +128,6 @@ export default function CategoryFilterBar({
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
// Collapse search if there are many categories
useEffect(() => {
setSearchCollapsed(categories.length >= 4);
}, [categories.length]);
const handleExpandSearch = () => {
setSearchCollapsed(false);
setTimeout(() => searchInputRef.current?.focus(), 50);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !onReorderCategories) return;
@ -254,32 +243,18 @@ export default function CategoryFilterBar({
<div className="flex-1" />
{/* Search */}
{searchCollapsed ? (
<button
type="button"
onClick={handleExpandSearch}
aria-label="Expand search"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-card-elevated transition-colors duration-150"
>
<Search className="h-4 w-4" />
</button>
) : (
<div className="relative transition-all duration-200">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<div className="relative shrink-0">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="search"
placeholder="Search..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={() => {
if (!searchValue && categories.length >= 4) setSearchCollapsed(true);
}}
className="w-44 h-8 pl-8 text-sm"
className="w-52 h-8 pl-8 text-sm ring-inset"
aria-label="Search"
/>
</div>
)}
</div>
);
}

View File

@ -11,6 +11,7 @@ export interface PanelField {
copyable?: boolean;
icon?: LucideIcon;
multiline?: boolean;
fullWidth?: boolean;
}
interface EntityDetailPanelProps<T> {
@ -81,30 +82,55 @@ export function EntityDetailPanel<T>({
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{fields.map((field) => {
const value = getValue(item, field.key);
if (!value) return null;
{(() => {
const gridFields = fields.filter((f) => !f.fullWidth && getValue(item, f.key));
const fullWidthFields = fields.filter((f) => f.fullWidth && getValue(item, f.key));
return (
<div key={field.key}>
<>
{gridFields.length > 0 && (
<div className="grid grid-cols-2 gap-3">
{gridFields.map((field) => {
const value = getValue(item, field.key)!;
return (
<div key={field.key} className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{field.icon && <field.icon className="h-3 w-3" />}
{field.label}
</div>
{field.copyable ? (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
{field.label}
</p>
<CopyableField value={value} icon={field.icon} label={field.label} />
</div>
) : (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
{field.label}
</p>
<p className={`text-sm ${field.multiline ? 'whitespace-pre-wrap' : ''}`}>{value}</p>
</div>
<p className="text-sm">{value}</p>
)}
</div>
);
})}
</div>
)}
{fullWidthFields.map((field) => {
const value = getValue(item, field.key)!;
return (
<div key={field.key} className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{field.icon && <field.icon className="h-3 w-3" />}
{field.label}
</div>
{field.copyable ? (
<CopyableField value={value} icon={field.icon} label={field.label} />
) : (
<p className={`text-sm ${field.multiline ? 'whitespace-pre-wrap text-muted-foreground leading-relaxed' : ''}`}>
{value}
</p>
)}
</div>
);
})}
</>
);
})()}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-border flex items-center justify-between">

View File

@ -0,0 +1,548 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO, isToday } from 'date-fns';
import {
X, Pencil, Trash2, Save, Clock, Calendar, Flag, Tag, Repeat, CheckSquare, AlertCircle, AlignLeft,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
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 { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
// --- Types ---
export interface TodoCreateDefaults {
category?: string;
}
interface TodoDetailPanelProps {
todo: Todo | null;
isCreating?: boolean;
createDefaults?: TodoCreateDefaults | null;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
}
interface EditState {
title: string;
description: string;
priority: string;
due_date: string;
due_time: string;
category: string;
recurrence_rule: string;
}
const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400',
};
const recurrenceLabels: Record<string, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
};
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
function buildEditState(todo: Todo): EditState {
return {
title: todo.title,
description: todo.description || '',
priority: todo.priority,
due_date: todo.due_date ? todo.due_date.slice(0, 10) : '',
due_time: todo.due_time ? todo.due_time.slice(0, 5) : '',
category: todo.category || '',
recurrence_rule: todo.recurrence_rule || '',
};
}
function buildCreateState(defaults?: TodoCreateDefaults | null): EditState {
return {
title: '',
description: '',
priority: 'medium',
due_date: '',
due_time: '',
category: defaults?.category || '',
recurrence_rule: '',
};
}
// --- Component ---
export default function TodoDetailPanel({
todo,
isCreating = false,
createDefaults,
onClose,
onSaved,
onDeleted,
}: TodoDetailPanelProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState<EditState>(() =>
isCreating ? buildCreateState(createDefaults) : todo ? buildEditState(todo) : buildCreateState()
);
// Reset state when todo changes
useEffect(() => {
setIsEditing(false);
if (todo) setEditState(buildEditState(todo));
}, [todo?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState(createDefaults));
}
}, [isCreating, createDefaults]);
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,
priority: data.priority,
due_date: data.due_date || null,
due_time: data.due_time || null,
category: data.category || null,
recurrence_rule: data.recurrence_rule || null,
};
if (todo && !isCreating) {
return api.put(`/todos/${todo.id}`, payload);
} else {
return api.post('/todos', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Todo created' : 'Todo updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create todo' : 'Failed to update todo'));
},
});
const toggleMutation = useMutation({
mutationFn: async () => {
const { data } = await api.patch(`/todos/${todo!.id}/toggle`);
return data;
},
onSuccess: () => {
invalidateAll();
toast.success(todo!.completed ? 'Todo marked incomplete' : 'Todo completed!');
},
onError: () => {
toast.error('Failed to update todo');
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/todos/${todo!.id}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Todo deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete todo'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (todo) setEditState(buildEditState(todo));
setIsEditing(true);
};
const handleEditCancel = () => {
setIsEditing(false);
if (isCreating) {
onClose();
} else if (todo) {
setEditState(buildEditState(todo));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!todo && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<CheckSquare className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select a todo to view details</p>
</div>
);
}
// View data
const dueDate = todo?.due_date ? parseISO(todo.due_date) : null;
const isDueToday = dueDate ? isToday(dueDate) : false;
const isOverdue = todo ? isTodoOverdue(todo.due_date, todo.completed) : 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="Todo title"
autoFocus
/>
) : isCreating ? (
<h3 className="font-heading text-lg font-semibold">New Todo</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={todo!.completed}
onChange={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending}
/>
</span>
<h3 className={`font-heading text-lg font-semibold truncate ${todo!.completed ? 'line-through text-muted-foreground' : ''}`}>
{todo!.title}
</h3>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
<>
<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>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit todo"
>
<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 todo"
>
<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 ? (
/* Edit / Create mode */
<div className="space-y-4">
{isCreating && (
<div className="space-y-1">
<Label htmlFor="todo-title" required>Title</Label>
<Input
id="todo-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Todo title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="todo-desc">Description</Label>
<Textarea
id="todo-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="todo-priority">Priority</Label>
<Select
id="todo-priority"
value={editState.priority}
onChange={(e) => updateField('priority', e.target.value)}
className="text-xs"
>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="todo-category">Category</Label>
<Input
id="todo-category"
value={editState.category}
onChange={(e) => updateField('category', e.target.value)}
placeholder="e.g., Work"
className="text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="todo-due-date">Due Date</Label>
<Input
id="todo-due-date"
type="date"
value={editState.due_date}
onChange={(e) => updateField('due_date', e.target.value)}
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="todo-due-time">Due Time</Label>
<Input
id="todo-due-time"
type="time"
value={editState.due_time}
onChange={(e) => updateField('due_time', e.target.value)}
className="text-xs"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="todo-recurrence">Recurrence</Label>
<Select
id="todo-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>
{/* 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: Priority, Category, Due Date, Recurrence */}
<div className="grid grid-cols-2 gap-3">
{/* Priority */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Flag className="h-3 w-3" />
Priority
</div>
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[todo!.priority] ?? ''}`}>
{todo!.priority}
</Badge>
</div>
{/* Category */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Tag className="h-3 w-3" />
Category
</div>
{todo!.category ? (
<Badge className="text-[9px] px-1.5 py-0.5 bg-blue-500/15 text-blue-400">
{todo!.category}
</Badge>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Due Date */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{isOverdue ? <AlertCircle className="h-3 w-3" /> : <Calendar className="h-3 w-3" />}
Due Date
</div>
{dueDate ? (
<CopyableField
value={`${isOverdue ? 'Overdue · ' : isDueToday ? 'Today · ' : ''}${format(dueDate, 'EEEE, MMMM d, yyyy')}${todo!.due_time ? ` at ${todo!.due_time.slice(0, 5)}` : ''}`}
icon={isOverdue ? AlertCircle : Calendar}
label="Due date"
/>
) : todo!.due_time ? (
<CopyableField value={todo!.due_time.slice(0, 5)} icon={Clock} label="Due time" />
) : (
<p className="text-sm text-muted-foreground"></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>
{todo!.recurrence_rule ? (
<p className="text-sm">{recurrenceLabels[todo!.recurrence_rule] || todo!.recurrence_rule}</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* Completion status — full width */}
{todo!.completed && todo!.completed_at && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<CheckSquare className="h-3 w-3" />
Completed
</div>
<p className="text-sm text-green-400">
{format(parseISO(todo!.completed_at), 'MMM d, yyyy · h:mm a')}
</p>
</div>
)}
{/* Reset info — full width */}
{todo!.completed && todo!.recurrence_rule && todo!.reset_at && (
<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" />
Resets
</div>
<p className="text-sm text-purple-400">
{format(parseISO(todo!.reset_at), 'EEE, MMM d')}
{todo!.next_due_date && ` · Next due ${format(parseISO(todo!.next_due_date), 'MMM d')}`}
</p>
</div>
)}
{/* Description — full width */}
{todo!.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">
{todo!.description}
</p>
</div>
)}
{/* Updated at */}
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(todo!.updated_at)}
</span>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -1,17 +1,17 @@
import { useState, useMemo } from 'react';
import { Plus, CheckSquare, CheckCircle2, AlertCircle, Search, ChevronDown } from 'lucide-react';
import { useState, useMemo, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { ListSkeleton } from '@/components/ui/skeleton';
import { CategoryFilterBar } from '@/components/shared';
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
import TodoList from './TodoList';
import TodoForm from './TodoForm';
import TodoDetailPanel from './TodoDetailPanel';
const priorityFilters = [
{ value: '', label: 'All' },
@ -22,14 +22,27 @@ const priorityFilters = [
] as const;
export default function TodosPage() {
const [showForm, setShowForm] = useState(false);
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [filters, setFilters] = useState({
priority: '',
category: '',
showCompleted: true,
search: '',
});
const location = useLocation();
// Panel state
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
// Filters
const [priorityFilter, setPriorityFilter] = useState('');
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [showCompleted, setShowCompleted] = useState(true);
const [search, setSearch] = useState('');
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { todoId?: number } | null;
if (state?.todoId) {
setSelectedTodoId(state.todoId);
setPanelMode('view');
window.history.replaceState({}, '');
}
}, [location.state]);
const { data: todos = [], isLoading } = useQuery({
queryKey: ['todos'],
@ -39,7 +52,7 @@ export default function TodosPage() {
},
});
const categories = useMemo(() => {
const allCategories = useMemo(() => {
const cats = new Set<string>();
todos.forEach((t) => {
if (t.category) cats.add(t.category);
@ -47,54 +60,92 @@ export default function TodosPage() {
return Array.from(cats).sort();
}, [todos]);
const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('todos', allCategories);
const filteredTodos = useMemo(
() =>
todos.filter((todo) => {
if (filters.priority && todo.priority !== filters.priority) return false;
if (filters.category && todo.category?.toLowerCase() !== filters.category.toLowerCase())
return false;
if (!filters.showCompleted && todo.completed) return false;
if (filters.search && !todo.title.toLowerCase().includes(filters.search.toLowerCase()))
if (priorityFilter && todo.priority !== priorityFilter) return false;
if (activeFilters.length > 0) {
if (!todo.category || !activeFilters.includes(todo.category)) return false;
}
if (!showCompleted && todo.completed) return false;
if (search && !todo.title.toLowerCase().includes(search.toLowerCase()))
return false;
return true;
}),
[todos, filters]
[todos, priorityFilter, activeFilters, showCompleted, search]
);
const totalCount = filteredTodos.filter((t) => !t.completed).length;
const completedCount = filteredTodos.filter((t) => t.completed).length;
const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length;
const handleEdit = (todo: Todo) => {
setEditingTodo(todo);
setShowForm(true);
const panelOpen = panelMode !== 'closed';
const selectedTodo = useMemo(
() => todos.find((t) => t.id === selectedTodoId) ?? null,
[selectedTodoId, todos],
);
const handleSelect = (todo: Todo) => {
setSelectedTodoId(todo.id);
setPanelMode('view');
};
const handleCloseForm = () => {
setShowForm(false);
setEditingTodo(null);
const handleCreateNew = () => {
setSelectedTodoId(null);
setPanelMode('create');
};
const handlePanelClose = () => {
setPanelMode('closed');
setSelectedTodoId(null);
};
// CategoryFilterBar handlers
const toggleAll = () => setActiveFilters([]);
const toggleCompleted = () => setShowCompleted((p) => !p);
const toggleCategory = (cat: string) => {
setActiveFilters((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
};
const selectAllCategories = () => {
const allSelected = orderedCategories.every((c) => activeFilters.includes(c));
setActiveFilters(allSelected ? [] : [...orderedCategories]);
};
// Escape key closes panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
{/* Priority filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
{priorityFilters.map((pf) => (
<button
key={pf.value}
onClick={() => setFilters({ ...filters, priority: pf.value })}
onClick={() => setPriorityFilter(pf.value)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
filters.priority === pf.value
priorityFilter === pf.value
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor:
filters.priority === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filters.priority === pf.value ? 'hsl(var(--accent-color))' : undefined,
priorityFilter === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: priorityFilter === pf.value ? 'hsl(var(--accent-color))' : undefined,
}}
>
{pf.label}
@ -102,54 +153,37 @@ export default function TodosPage() {
))}
</div>
<div className="relative ml-2">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-52 h-8 pl-8 text-sm"
{/* Category filter bar (All + Completed + Categories with drag) */}
<div className="flex-1 min-w-0">
<CategoryFilterBar
activeFilters={activeFilters}
pinnedLabel="Completed"
showPinned={showCompleted}
categories={orderedCategories}
onToggleAll={toggleAll}
onTogglePinned={toggleCompleted}
onToggleCategory={toggleCategory}
onSelectAllCategories={selectAllCategories}
onReorderCategories={reorderCategories}
searchValue={search}
onSearchChange={setSearch}
/>
</div>
<div className="relative">
<select
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="h-8 rounded-md border border-input bg-background px-3 pr-8 text-sm text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 appearance-none cursor-pointer"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="show-completed"
checked={filters.showCompleted}
onChange={(e) =>
setFilters({ ...filters, showCompleted: (e.target as HTMLInputElement).checked })
}
/>
<Label htmlFor="show-completed" className="text-xs text-muted-foreground cursor-pointer">
Completed
</Label>
</div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm">
<Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Todo
</Button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Main content — list + detail panel */}
<div className="flex-1 overflow-hidden flex">
<div
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="px-6 py-5">
{/* Summary stats */}
{!isLoading && todos.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5">
@ -200,13 +234,47 @@ export default function TodosPage() {
) : (
<TodoList
todos={filteredTodos}
onEdit={handleEdit}
onAdd={() => setShowForm(true)}
onEdit={handleSelect}
onAdd={handleCreateNew}
/>
)}
</div>
</div>
{showForm && <TodoForm todo={editingTodo} onClose={handleCloseForm} />}
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<TodoDetailPanel
todo={panelMode === 'view' ? selectedTodo : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<TodoDetailPanel
todo={panelMode === 'view' ? selectedTodo : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
)}
</div>
);
}