Compare commits

...

18 Commits

Author SHA1 Message Date
e51b09f9c5 Merge feature/mobile-responsive into main
Comprehensive mobile-responsive UI across all frontend pages:
- Global font scaling, responsive grids, progressive disclosure
- Mobile card views, touch-optimized inputs, bottom-sheet DatePicker
- Admin portal responsive tables, evenly spaced tab nav
- KanbanBoard touch drag-and-drop, FullCalendar mobile styling
- isDesktop media query guards for detail panels (no dual mount)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 03:14:58 +08:00
89f72895c1 Fix QA findings: dual panel mount, touch-action, font floor, a11y
- Replace CSS-only panel hiding with isDesktop media query guard
  in Todos, Reminders, People, Locations, ProjectDetail (W-01)
- Add touch-action: manipulation for mobile interactive elements (W-04)
- Bump FullCalendar more-link from 0.55rem to 0.625rem (W-07)
- Add aria-label on admin portal tab NavLinks (S-05)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 03:14:38 +08:00
98ad83ae5f Evenly space admin portal tab navigation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:59:23 +08:00
84b3083987 Admin portal mobile responsiveness: tables, grids, and nav
- Tab nav: scroll isolation, icon-only on mobile, accessible titles
- IAM table: hide 6 columns on mobile, responsive padding
- User detail: responsive grid (1→2→3 cols), role select sizing
- Dashboard: responsive stats grid, hide Actor/Target cols on mobile
- Audit log: responsive column hiding and padding
- Actions menu: role submenu repositions below trigger on mobile
- Config: narrower filter select on mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:54:23 +08:00
db16a07f68 Fix project title cutoff on mobile in ProjectDetail header
Reduce header gap to gap-2 on mobile, add min-w-0 so title can
shrink properly, hide status badge on small screens, and add
shrink-0 to action buttons to prevent them from compressing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:24:09 +08:00
9b41cb5003 Fix task title truncation on mobile in Projects tab
Hide verbose metadata columns (status badge, priority badge, date,
subtask count) on mobile and replace with compact priority dot +
overdue indicator. Reduce subtask indent and stack project summary
card vertically on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:20:53 +08:00
0fc2d05085 Fix calendar view dropdown clipping and title overlap on mobile
Add pr-8 to mobile view Select to prevent text clipping under chevron.
Add min-w-0 flex-shrink to calendar title h2 to prevent nav arrow overlap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:14:35 +08:00
56175aaf86 Fix calendar popover, dropdown clipping, and header spacing across all tabs
Add dark-themed FullCalendar "+more" popover with CSS X close button
(replaces broken font icon). Add pr-8 to all mobile Select dropdowns
to prevent text clipping under chevron. Normalize header gap to
gap-2 md:gap-4 across all page headers for tighter mobile layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:13:41 +08:00
023fa86b65 Mobile UI polish: global font scaling, tighter dashboard, cleaner calendar
Scale down all content text on mobile via .mobile-scale CSS class (excludes
navbar/UMBRA title). Hide calendar event times in month view (Google Calendar
style). Restructure CategoryFilterBar so categories display on a separate row
when toggled instead of being hidden behind the search bar. Reduce dashboard
widget density with hidden badges and tighter spacing on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 01:56:53 +08:00
ec8f5a9b4e Fix mobile density issues from S24 Ultra testing
- Page titles: text-xl on mobile, text-2xl on desktop (7 pages)
- Stat cards: reduce padding/gap on mobile, hide icons below sm (3 pages)
- TodoItem: two-line layout on mobile (title row + metadata row)
- ReminderItem: same two-line treatment
- FullCalendar: smaller event font/padding on mobile via CSS media query

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:02:42 +08:00
0b84352b09 Fix KanbanBoard: actually wire TouchSensor into useSensors
The import was added but the sensors config replacement failed silently
due to line ending mismatch. TouchSensor now properly registered with
200ms delay / 5px tolerance alongside PointerSensor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:42:27 +08:00
4d5052d731 Action QA findings: fix all critical/warning/suggestion items
Critical fixes:
- C-01: DatePicker isMobile now actually used for bottom sheet positioning
- C-02: Calendar title always visible (text-sm on mobile, text-lg on sm+)
- C-03: Mobile card text-[10px] → text-xs (meets 12px minimum)

Warning fixes:
- W-01: useMediaQuery SSR-safe (typeof window guard)
- W-02: KanbanBoard TouchSensor added (was lost during branch ops)
- W-03: Removed duplicate isMobile query, derived from !isDesktop
- W-04: Search restored on mobile for Calendar/Reminders/Projects (w-32 sm:w-52)
- W-05: SheetClose added to CalendarSidebar mobile Sheet
- W-06: Button icon uses min-h/min-w for touch targets instead of h-11

Suggestion fixes:
- S-01: Removed deprecated WebkitOverflowScrolling from KanbanBoard
- S-02: Added role/tabIndex/onKeyDown to EntityTable mobile card wrappers
- S-03: Added overflow-y-auto to mobile event detail panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:16:47 +08:00
f7ec04241b Phase 4: mobile polish and touch fallbacks
4a. Touch fallbacks for group-hover actions:
  - 9 occurrences across 5 files changed from opacity-0 group-hover:opacity-100
    to opacity-100 md:opacity-0 md:group-hover:opacity-100
  - CalendarSidebar (3), SharedCalendarSection (2), TaskDetailPanel (2),
    NotificationsPage (1), CopyableField (1)
  - Action buttons now always visible on touch, hover-revealed on desktop

4b. FullCalendar mobile touch:
  - Wheel navigation disabled on touch devices (ontouchstart check)
  - Prevents scroll hijacking on mobile, allows native scroll

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:04:44 +08:00
b05adf7f12 Phase 3: complex component mobile adaptations
3a. CalendarSidebar mobile collapse:
  - Desktop sidebar + resize handle hidden below lg breakpoint
  - Mobile Sheet overlay with PanelLeft toggle in toolbar
  - Template selection closes mobile sidebar automatically

3b. KanbanBoard touch support:
  - TouchSensor added alongside PointerSensor (200ms delay)
  - Column min-width reduced on mobile (160px vs 200px)
  - iOS smooth scroll enabled on horizontal container

3c. EntityTable mobile card view:
  - mobileCardRender optional prop renders cards instead of table on mobile
  - PeoplePage: card with name, category, email, phone
  - LocationsPage: card with name, category, address
  - TodosPage/RemindersPage use custom list components, not EntityTable

3d. DatePicker mobile bottom sheet:
  - Renders as full-width bottom sheet on mobile (< 768px)
  - Safe area inset padding for iOS home indicator
  - Desktop positioned dropdown unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:03:14 +08:00
d8f7f7ac92 Merge mobile card view into feature/mobile-responsive 2026-03-07 17:01:13 +08:00
09c35752c6 Add mobile card view to EntityTable with renderers for People and Locations
- EntityTable: add useMediaQuery hook, mobileCardRender prop, and mobile card path
  that replaces the table on screens <768px when a renderer is provided
- PeoplePage: add mobileCardRender showing name, category, email, phone
- LocationsPage: add mobileCardRender showing name, category, address

Note: TodosPage and RemindersPage use custom list components (TodoList,
ReminderList), not EntityTable directly — no changes needed there.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:59:58 +08:00
d0477b1c13 Phase 2: toolbar responsive patterns
- All page toolbars now flex-wrap on mobile with min-h instead of fixed h-16
- Segmented button filters (priority, status, view) hidden on mobile, replaced
  with compact Select dropdowns
- Search inputs hidden on mobile where CategoryFilterBar already has search
- CategoryFilterBar wraps to full-width row on mobile (order-last)
- Action buttons show icon-only on mobile, full text on md+
- Calendar title hidden on xs screens for space
- Desktop layout completely unchanged (md:flex-nowrap restores original)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:56:08 +08:00
1c16df4db0 Phase 1: mobile responsive foundation
- useMediaQuery hook extracted from CalendarPage inline pattern
- h-screen → h-dvh for mobile address bar viewport fix
- px-6 → px-4 md:px-6 on all page containers/toolbars (14 files)
- Input/Select text-base on mobile to prevent iOS auto-zoom
- Sheet full-width on mobile, max-w-[540px] on sm+
- Button icon size touch-friendly (44px mobile, 40px desktop)
- Tailwind hoverOnlyWhenSupported: true (fixes 157 hover interactions)
- PWA meta tags (apple-mobile-web-app-capable, theme-color)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:51:53 +08:00
41 changed files with 1088 additions and 769 deletions

View File

@ -3,6 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#09090b" />
<meta name="mobile-web-app-capable" content="yes" />
<title>UMBRA</title> <title>UMBRA</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -21,7 +21,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex h-dvh items-center justify-center">
<div className="text-muted-foreground">Loading...</div> <div className="text-muted-foreground">Loading...</div>
</div> </div>
); );
@ -39,7 +39,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex h-dvh items-center justify-center">
<div className="text-muted-foreground">Loading...</div> <div className="text-muted-foreground">Loading...</div>
</div> </div>
); );

View File

@ -23,9 +23,9 @@ export default function AdminDashboardPage() {
dashboard ? dashboard.total_users - dashboard.active_users : null; dashboard ? dashboard.total_users - dashboard.active_users : null;
return ( return (
<div className="px-6 py-6 space-y-6 animate-fade-in"> <div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
{/* Stats grid */} {/* Stats grid */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5"> <div className="grid gap-2.5 grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{isLoading ? ( {isLoading ? (
Array.from({ length: 5 }).map((_, i) => ( Array.from({ length: 5 }).map((_, i) => (
<Card key={i}> <Card key={i}>
@ -94,10 +94,10 @@ export default function AdminDashboardPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-card-elevated/50"> <tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username Username
</th> </th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
When When
</th> </th>
</tr> </tr>
@ -111,8 +111,8 @@ export default function AdminDashboardPage() {
idx % 2 === 0 ? '' : 'bg-card-elevated/25' idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)} )}
> >
<td className="px-5 py-2.5 font-medium">{entry.username}</td> <td className="px-3 lg:px-5 py-2.5 font-medium">{entry.username}</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground"> <td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground">
{getRelativeTime(entry.last_login_at)} {getRelativeTime(entry.last_login_at)}
</td> </td>
</tr> </tr>
@ -142,16 +142,16 @@ export default function AdminDashboardPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-card-elevated/50"> <tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Action Action
</th> </th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
Actor Actor
</th> </th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
Target Target
</th> </th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
When When
</th> </th>
</tr> </tr>
@ -165,7 +165,7 @@ export default function AdminDashboardPage() {
idx % 2 === 0 ? '' : 'bg-card-elevated/25' idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)} )}
> >
<td className="px-5 py-2.5"> <td className="px-3 lg:px-5 py-2.5">
<span <span
className={cn( className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap', 'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
@ -175,15 +175,15 @@ export default function AdminDashboardPage() {
{entry.action} {entry.action}
</span> </span>
</td> </td>
<td className="px-5 py-2.5 text-xs font-medium"> <td className="px-3 lg:px-5 py-2.5 text-xs font-medium hidden sm:table-cell">
{entry.actor_username ?? ( {entry.actor_username ?? (
<span className="text-muted-foreground italic">system</span> <span className="text-muted-foreground italic">system</span>
)} )}
</td> </td>
<td className="px-5 py-2.5 text-xs text-muted-foreground"> <td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground hidden sm:table-cell">
{entry.target_username ?? '—'} {entry.target_username ?? '—'}
</td> </td>
<td className="px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap"> <td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
{getRelativeTime(entry.created_at)} {getRelativeTime(entry.created_at)}
</td> </td>
</tr> </tr>

View File

@ -17,32 +17,34 @@ export default function AdminPortal() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Portal header with tab navigation */} {/* Portal header with tab navigation */}
<div className="shrink-0 border-b bg-card"> <div className="shrink-0 border-b bg-card overflow-hidden">
<div className="px-6 h-16 flex items-center gap-4"> <div className="px-3 md:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-4">
<div className="flex items-center gap-2 mr-6"> <div className="flex items-center gap-2 shrink-0">
<div className="p-1.5 rounded-md bg-red-500/10"> <div className="p-1.5 rounded-md bg-red-500/10">
<ShieldCheck className="h-5 w-5 text-red-400" /> <ShieldCheck className="h-5 w-5 text-red-400" />
</div> </div>
<h1 className="font-heading text-2xl font-bold tracking-tight">Admin Portal</h1> <h1 className="font-heading text-base md:text-2xl font-bold tracking-tight">Admin</h1>
</div> </div>
{/* Horizontal tab navigation */} {/* Horizontal tab navigation */}
<nav className="flex items-center gap-1 h-full"> <nav className="flex items-center justify-evenly flex-1 h-full min-w-0 overflow-x-auto">
{tabs.map(({ label, path, icon: Icon }) => { {tabs.map(({ label, path, icon: Icon }) => {
const isActive = location.pathname.startsWith(path); const isActive = location.pathname.startsWith(path);
return ( return (
<NavLink <NavLink
key={path} key={path}
to={path} to={path}
title={label}
aria-label={label}
className={cn( className={cn(
'flex items-center gap-2 px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px', 'flex items-center justify-center gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
isActive isActive
? 'text-accent border-accent' ? 'text-accent border-accent'
: 'text-muted-foreground hover:text-foreground border-transparent' : 'text-muted-foreground hover:text-foreground border-transparent'
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4 shrink-0" />
{label} <span className="hidden sm:inline">{label}</span>
</NavLink> </NavLink>
); );
})} })}

View File

@ -54,7 +54,7 @@ export default function ConfigPage() {
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1; const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
return ( return (
<div className="px-6 py-6 space-y-6 animate-fade-in"> <div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
<Card> <Card>
<CardHeader className="flex-row items-center justify-between flex-wrap gap-3"> <CardHeader className="flex-row items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -75,7 +75,7 @@ export default function ConfigPage() {
<Filter className="h-3.5 w-3.5 text-muted-foreground" /> <Filter className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Filter:</span> <span className="text-xs text-muted-foreground">Filter:</span>
</div> </div>
<div className="w-52"> <div className="w-36 sm:w-52">
<Select <Select
value={filterAction} value={filterAction}
onChange={(e) => { onChange={(e) => {
@ -129,22 +129,22 @@ export default function ConfigPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-card-elevated/50"> <tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Time Time
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
Actor Actor
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Action Action
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
Target Target
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
IP IP
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Detail Detail
</th> </th>
</tr> </tr>
@ -158,15 +158,15 @@ export default function ConfigPage() {
idx % 2 === 0 ? '' : 'bg-card-elevated/25' idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)} )}
> >
<td className="px-5 py-3 text-xs text-muted-foreground whitespace-nowrap"> <td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
{getRelativeTime(entry.created_at)} {getRelativeTime(entry.created_at)}
</td> </td>
<td className="px-5 py-3 text-xs font-medium"> <td className="px-3 lg:px-5 py-3 text-xs font-medium hidden sm:table-cell">
{entry.actor_username ?? ( {entry.actor_username ?? (
<span className="text-muted-foreground italic">system</span> <span className="text-muted-foreground italic">system</span>
)} )}
</td> </td>
<td className="px-5 py-3"> <td className="px-3 lg:px-5 py-3">
<span <span
className={cn( className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap', 'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
@ -176,13 +176,13 @@ export default function ConfigPage() {
{entry.action} {entry.action}
</span> </span>
</td> </td>
<td className="px-5 py-3 text-xs text-muted-foreground"> <td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground hidden sm:table-cell">
{entry.target_username ?? '—'} {entry.target_username ?? '—'}
</td> </td>
<td className="px-5 py-3 text-xs text-muted-foreground font-mono"> <td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground font-mono hidden lg:table-cell">
{entry.ip_address ?? '—'} {entry.ip_address ?? '—'}
</td> </td>
<td className="px-5 py-3 text-xs text-muted-foreground max-w-xs truncate"> <td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground max-w-xs truncate hidden lg:table-cell">
{entry.detail ?? '—'} {entry.detail ?? '—'}
</td> </td>
</tr> </tr>

View File

@ -95,7 +95,7 @@ export default function IAMPage() {
: null; : null;
return ( return (
<div className="px-6 py-6 space-y-6 animate-fade-in"> <div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
{/* Stats row */} {/* Stats row */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4"> <div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
@ -125,7 +125,7 @@ export default function IAMPage() {
{/* User table */} {/* User table */}
<Card> <Card>
<CardHeader className="flex-row items-center justify-between gap-3"> <CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10"> <div className="p-1.5 rounded-md bg-accent/10">
<Users className="h-4 w-4 text-accent" /> <Users className="h-4 w-4 text-accent" />
@ -139,12 +139,12 @@ export default function IAMPage() {
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search users..." placeholder="Search users..."
className="pl-8 h-8 w-48 text-xs" className="pl-8 h-8 w-32 sm:w-48 text-xs"
/> />
</div> </div>
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create User <span className="hidden sm:inline">Create User</span>
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@ -160,38 +160,38 @@ export default function IAMPage() {
{searchQuery ? 'No users match your search.' : 'No users found.'} {searchQuery ? 'No users match your search.' : 'No users found.'}
</p> </p>
) : ( ) : (
<div> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-card-elevated/50"> <tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username Username
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Umbral Name Umbral Name
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Email Email
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Role Role
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Status Status
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Last Login Last Login
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
MFA MFA
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Sessions Sessions
</th> </th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Created Created
</th> </th>
<th className="px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium"> <th className="px-3 lg:px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Actions Actions
</th> </th>
</tr> </tr>
@ -211,17 +211,17 @@ export default function IAMPage() {
) )
)} )}
> >
<td className="px-5 py-3 font-medium">{user.username}</td> <td className="px-3 lg:px-5 py-3 font-medium">{user.username}</td>
<td className="px-5 py-3 text-muted-foreground text-xs"> <td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
{user.umbral_name || user.username} {user.umbral_name || user.username}
</td> </td>
<td className="px-5 py-3 text-muted-foreground text-xs"> <td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
{user.email || '—'} {user.email || '—'}
</td> </td>
<td className="px-5 py-3"> <td className="px-3 lg:px-5 py-3">
<RoleBadge role={user.role} /> <RoleBadge role={user.role} />
</td> </td>
<td className="px-5 py-3"> <td className="px-3 lg:px-5 py-3">
<span <span
className={cn( className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide', 'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
@ -233,10 +233,10 @@ export default function IAMPage() {
{user.is_active ? 'Active' : 'Disabled'} {user.is_active ? 'Active' : 'Disabled'}
</span> </span>
</td> </td>
<td className="px-5 py-3 text-muted-foreground text-xs"> <td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
{user.last_login_at ? getRelativeTime(user.last_login_at) : '—'} {user.last_login_at ? getRelativeTime(user.last_login_at) : '—'}
</td> </td>
<td className="px-5 py-3"> <td className="px-3 lg:px-5 py-3 hidden lg:table-cell">
{user.totp_enabled ? ( {user.totp_enabled ? (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400"> <span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
On On
@ -249,13 +249,13 @@ export default function IAMPage() {
<span className="text-muted-foreground text-xs"></span> <span className="text-muted-foreground text-xs"></span>
)} )}
</td> </td>
<td className="px-5 py-3 text-muted-foreground text-xs tabular-nums"> <td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs tabular-nums hidden lg:table-cell">
{user.active_sessions} {user.active_sessions}
</td> </td>
<td className="px-5 py-3 text-muted-foreground text-xs"> <td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
{getRelativeTime(user.created_at)} {getRelativeTime(user.created_at)}
</td> </td>
<td className="px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}> <td className="px-3 lg:px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<UserActionsMenu user={user} currentUsername={authStatus?.username ?? null} /> <UserActionsMenu user={user} currentUsername={authStatus?.username ?? null} />
</td> </td>
</tr> </tr>

View File

@ -147,7 +147,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
{roleSubmenuOpen && ( {roleSubmenuOpen && (
<div <div
className="absolute right-full top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1" className="absolute left-0 top-full sm:left-auto sm:right-full sm:top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
onMouseEnter={() => setRoleSubmenuOpen(true)} onMouseEnter={() => setRoleSubmenuOpen(true)}
onMouseLeave={() => setRoleSubmenuOpen(false)} onMouseLeave={() => setRoleSubmenuOpen(false)}
> >

View File

@ -71,15 +71,15 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
if (isLoading) { if (isLoading) {
return ( return (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
<Card className="col-span-1"> <Card>
<CardContent className="p-5 space-y-3"> <CardContent className="p-5 space-y-3">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" /> <Skeleton key={i} className="h-5 w-full" />
))} ))}
</CardContent> </CardContent>
</Card> </Card>
<Card className="col-span-1"> <Card>
<CardContent className="p-5 space-y-3"> <CardContent className="p-5 space-y-3">
{Array.from({ length: 7 }).map((_, i) => ( {Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" /> <Skeleton key={i} className="h-5 w-full" />
@ -109,9 +109,9 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
if (!user) return null; if (!user) return null;
return ( return (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
{/* User Information (read-only) */} {/* User Information (read-only) */}
<Card className="col-span-1"> <Card>
<CardHeader className="flex-row items-center justify-between pb-3"> <CardHeader className="flex-row items-center justify-between pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10"> <div className="p-1.5 rounded-md bg-accent/10">
@ -152,7 +152,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
</Card> </Card>
{/* Security & Permissions */} {/* Security & Permissions */}
<Card className="col-span-1"> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10"> <div className="p-1.5 rounded-md bg-accent/10">
@ -168,7 +168,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
<Select <Select
value={user.role} value={user.role}
onChange={(e) => handleRoleChange(e.target.value as UserRole)} onChange={(e) => handleRoleChange(e.target.value as UserRole)}
className="h-6 text-xs py-0 px-1.5 w-auto min-w-[120px]" className="h-6 text-xs py-0 px-1.5 w-auto min-w-[100px] sm:min-w-[120px]"
disabled={updateRole.isPending} disabled={updateRole.isPending}
> >
<option value="admin">Admin</option> <option value="admin">Admin</option>
@ -221,7 +221,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
</Card> </Card>
{/* Sharing Stats */} {/* Sharing Stats */}
<Card className="col-span-1"> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10"> <div className="p-1.5 rounded-md bg-accent/10">

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -7,7 +8,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid'; import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import axios from 'axios'; import axios from 'axios';
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types'; import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
@ -15,6 +16,8 @@ import { useCalendars } from '@/hooks/useCalendars';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import CalendarSidebar from './CalendarSidebar'; import CalendarSidebar from './CalendarSidebar';
import EventDetailPanel from './EventDetailPanel'; import EventDetailPanel from './EventDetailPanel';
import type { CreateDefaults } from './EventDetailPanel'; import type { CreateDefaults } from './EventDetailPanel';
@ -161,13 +164,8 @@ export default function CalendarPage() {
const panelOpen = panelMode !== 'closed'; const panelOpen = panelMode !== 'closed';
// Track desktop breakpoint to prevent dual EventDetailPanel mount // Track desktop breakpoint to prevent dual EventDetailPanel mount
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches); const isDesktop = useMediaQuery('(min-width: 1024px)');
useEffect(() => { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const mql = window.matchMedia('(min-width: 1024px)');
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
// Continuously resize calendar during panel open/close CSS transition // Continuously resize calendar during panel open/close CSS transition
useEffect(() => { useEffect(() => {
@ -190,6 +188,8 @@ export default function CalendarPage() {
if (!el) return; if (!el) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
// Skip wheel navigation on touch devices (let them scroll normally)
if ('ontouchstart' in window) return;
const api = calendarRef.current?.getApi(); const api = calendarRef.current?.getApi();
if (!api || api.view.type !== 'dayGridMonth') return; if (!api || api.view.type !== 'dayGridMonth') return;
e.preventDefault(); e.preventDefault();
@ -475,15 +475,29 @@ export default function CalendarPage() {
return ( return (
<div className="flex h-full overflow-hidden animate-fade-in"> <div className="flex h-full overflow-hidden animate-fade-in">
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} /> <div className="hidden lg:flex lg:flex-row shrink-0">
<div <CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
onMouseDown={handleSidebarMouseDown} <div
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150" onMouseDown={handleSidebarMouseDown}
/> className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
/>
</div>
{!isDesktop && (
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetContent className="w-72 p-0">
<SheetClose onClick={() => setMobileSidebarOpen(false)} />
<CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} />
</SheetContent>
</Sheet>
)}
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden"> <div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar */} {/* Custom toolbar */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setMobileSidebarOpen(true)}>
<PanelLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@ -496,7 +510,17 @@ export default function CalendarPage() {
Today Today
</Button> </Button>
<div className="flex items-center rounded-md border border-border overflow-hidden"> <Select
value={currentView}
onChange={(e) => changeView(e.target.value as CalendarView)}
className="h-8 text-sm w-auto pr-8 md:hidden"
>
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<option key={view} value={view}>{label}</option>
))}
</Select>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<button <button
key={view} key={view}
@ -516,7 +540,7 @@ export default function CalendarPage() {
))} ))}
</div> </div>
<h2 className="text-lg font-semibold font-heading">{calendarTitle}</h2> <h2 className="text-sm sm:text-lg font-semibold font-heading truncate min-w-0 flex-shrink">{calendarTitle}</h2>
<div className="flex-1" /> <div className="flex-1" />
@ -529,7 +553,7 @@ export default function CalendarPage() {
onChange={(e) => setEventSearch(e.target.value)} onChange={(e) => setEventSearch(e.target.value)}
onFocus={() => setSearchFocused(true)} onFocus={() => setSearchFocused(true)}
onBlur={() => setTimeout(() => setSearchFocused(false), 200)} onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
className="w-52 h-8 pl-8 text-sm ring-inset" className="w-32 sm:w-52 h-8 pl-8 text-sm ring-inset"
/> />
{searchFocused && searchResults.length > 0 && ( {searchFocused && searchResults.length > 0 && (
<div className="absolute z-50 mt-1 w-72 right-0 rounded-md border bg-popover shadow-lg overflow-hidden"> <div className="absolute z-50 mt-1 w-72 right-0 rounded-md border bg-popover shadow-lg overflow-hidden">
@ -558,8 +582,7 @@ export default function CalendarPage() {
</div> </div>
<Button size="sm" onClick={handleCreateNew}> <Button size="sm" onClick={handleCreateNew}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Create Event</span>
Create Event
</Button> </Button>
</div> </div>
@ -621,7 +644,7 @@ export default function CalendarPage() {
onClick={handlePanelClose} onClick={handlePanelClose}
> >
<div <div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg" className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg overflow-y-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<EventDetailPanel <EventDetailPanel

View File

@ -131,7 +131,7 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span> <span className="text-sm text-foreground truncate flex-1">{cal.name}</span>
<button <button
onClick={() => handleEdit(cal)} onClick={() => handleEdit(cal)}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground" className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
@ -184,7 +184,7 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
setEditingTemplate(tmpl); setEditingTemplate(tmpl);
setShowTemplateForm(true); setShowTemplateForm(true);
}} }}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground" className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</button> </button>
@ -194,7 +194,7 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
if (!window.confirm(`Delete template "${tmpl.name}"?`)) return; if (!window.confirm(`Delete template "${tmpl.name}"?`)) return;
deleteTemplateMutation.mutate(tmpl.id); deleteTemplateMutation.mutate(tmpl.id);
}} }}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-destructive" className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-destructive"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</button> </button>

View File

@ -73,7 +73,7 @@ export default function SharedCalendarSection({
<button <button
type="button" type="button"
onClick={() => onEditCalendar?.(cal)} onClick={() => onEditCalendar?.(cal)}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground" className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
@ -104,7 +104,7 @@ export default function SharedCalendarSection({
<span className="text-sm text-foreground truncate flex-1">{m.calendar_name}</span> <span className="text-sm text-foreground truncate flex-1">{m.calendar_name}</span>
<button <button
onClick={() => setSettingsFor(m)} onClick={() => setSettingsFor(m)}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground" className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>

View File

@ -86,13 +86,13 @@ export default function DashboardPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="px-6 py-6"> <div className="px-4 md:px-6 py-6">
<div className="animate-pulse space-y-2"> <div className="animate-pulse space-y-2">
<div className="h-8 w-48 rounded bg-muted" /> <div className="h-8 w-48 rounded bg-muted" />
<div className="h-4 w-32 rounded bg-muted" /> <div className="h-4 w-32 rounded bg-muted" />
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 pb-6"> <div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
<DashboardSkeleton /> <DashboardSkeleton />
</div> </div>
</div> </div>
@ -110,7 +110,7 @@ export default function DashboardPage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header — greeting + date + quick add */} {/* Header — greeting + date + quick add */}
<div className="px-6 pt-6 pb-2 flex items-center justify-between"> <div className="px-4 md:px-6 pt-4 sm:pt-6 pb-1 sm:pb-2 flex items-center justify-between">
<div> <div>
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in"> <h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
{getGreeting(settings?.preferred_name || undefined)} {getGreeting(settings?.preferred_name || undefined)}
@ -156,8 +156,8 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 pb-6"> <div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
<div className="space-y-5"> <div className="space-y-3 sm:space-y-5">
{/* Week Timeline */} {/* Week Timeline */}
{upcomingData && ( {upcomingData && (
<div className="animate-slide-up"> <div className="animate-slide-up">
@ -187,7 +187,7 @@ export default function DashboardPage() {
<AlertBanner alerts={alerts} onDismiss={dismissAlert} onSnooze={snoozeAlert} /> <AlertBanner alerts={alerts} onDismiss={dismissAlert} onSnooze={snoozeAlert} />
{/* Main Content — 2 columns */} {/* Main Content — 2 columns */}
<div className="grid gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}> <div className="grid gap-3 sm:gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
{/* Left: Upcoming feed (wider) */} {/* Left: Upcoming feed (wider) */}
<div className="lg:col-span-3 flex flex-col"> <div className="lg:col-span-3 flex flex-col">
{upcomingData && upcomingData.items.length > 0 ? ( {upcomingData && upcomingData.items.length > 0 ? (
@ -207,7 +207,7 @@ export default function DashboardPage() {
</div> </div>
{/* Right: Countdown + Today's events + todos stacked */} {/* Right: Countdown + Today's events + todos stacked */}
<div className="lg:col-span-2 flex flex-col gap-5"> <div className="lg:col-span-2 flex flex-col gap-3 sm:gap-5">
{data.starred_events.length > 0 && ( {data.starred_events.length > 0 && (
<CountdownWidget events={data.starred_events} /> <CountdownWidget events={data.starred_events} />
)} )}

View File

@ -42,7 +42,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
]; ];
return ( return (
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4"> <div className="grid gap-1.5 sm:gap-2.5 grid-cols-2 sm:grid-cols-4">
{statCards.map((stat) => ( {statCards.map((stat) => (
<Card <Card
key={stat.label} key={stat.label}

View File

@ -86,7 +86,7 @@ export default function TrackedProjectsWidget() {
<span className="text-[11px] text-muted-foreground truncate block">{task.parent_task_title}</span> <span className="text-[11px] text-muted-foreground truncate block">{task.parent_task_title}</span>
)} )}
</div> </div>
<span className="text-[11px] text-muted-foreground truncate shrink-0 max-w-[5rem]">{task.project_name}</span> <span className="text-[11px] text-muted-foreground truncate shrink-0 max-w-[5rem] hidden sm:block">{task.project_name}</span>
<span className={cn( <span className={cn(
'text-xs shrink-0 whitespace-nowrap tabular-nums', 'text-xs shrink-0 whitespace-nowrap tabular-nums',
overdue ? 'text-red-400' : isToday(dueDate) ? 'text-accent' : 'text-muted-foreground' overdue ? 'text-red-400' : isToday(dueDate) ? 'text-accent' : 'text-muted-foreground'
@ -94,7 +94,7 @@ export default function TrackedProjectsWidget() {
{isToday(dueDate) ? 'Today' : format(dueDate, 'MMM d')} {isToday(dueDate) ? 'Today' : format(dueDate, 'MMM d')}
</span> </span>
<span className={cn( <span className={cn(
'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0', 'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 hidden sm:block',
statusBadgeColors[task.status] || 'bg-gray-500/10 text-gray-400' statusBadgeColors[task.status] || 'bg-gray-500/10 text-gray-400'
)}> )}>
{statusLabels[task.status] || task.status} {statusLabels[task.status] || task.status}

View File

@ -75,11 +75,11 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
? format(new Date(item.datetime), 'MMM d, h:mm a') ? format(new Date(item.datetime), 'MMM d, h:mm a')
: format(new Date(item.date), 'MMM d')} : format(new Date(item.date), 'MMM d')}
</span> </span>
<span className={cn('text-[9px] font-semibold uppercase tracking-wider shrink-0 w-14 text-right', config.color)}> <span className={cn('text-[9px] font-semibold uppercase tracking-wider shrink-0 w-14 text-right hidden sm:block', config.color)}>
{config.label} {config.label}
</span> </span>
<span className={cn( <span className={cn(
'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 w-14 text-center', 'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 w-14 text-center hidden sm:block',
item.priority === 'high' ? 'bg-red-500/10 text-red-400' : item.priority === 'high' ? 'bg-red-500/10 text-red-400' :
item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' : item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' :
item.priority === 'low' ? 'bg-green-500/10 text-green-400' : item.priority === 'low' ? 'bg-green-500/10 text-green-400' :

View File

@ -39,13 +39,13 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
}, [weekStart, today, items]); }, [weekStart, today, items]);
return ( return (
<div className="flex items-stretch gap-2"> <div className="flex items-stretch gap-1 sm:gap-2">
{days.map((day) => ( {days.map((day) => (
<div <div
key={day.key} key={day.key}
onClick={() => navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })} onClick={() => navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })}
className={cn( className={cn(
'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border cursor-pointer', 'flex-1 flex flex-col items-center gap-1 sm:gap-1.5 rounded-lg py-2 sm:py-3 px-1 sm:px-2 transition-all duration-200 border cursor-pointer',
day.isToday day.isToday
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]' ? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
: day.isPast : day.isPast
@ -55,7 +55,7 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
> >
<span <span
className={cn( className={cn(
'text-[11px] font-medium uppercase tracking-wider', 'text-[9px] sm:text-[11px] font-medium uppercase tracking-wider',
day.isToday ? 'text-accent' : 'text-muted-foreground' day.isToday ? 'text-accent' : 'text-muted-foreground'
)} )}
> >
@ -63,7 +63,7 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
</span> </span>
<span <span
className={cn( className={cn(
'font-heading text-lg font-semibold leading-none', 'font-heading text-sm sm:text-lg font-semibold leading-none',
day.isToday ? 'text-accent' : 'text-foreground' day.isToday ? 'text-accent' : 'text-foreground'
)} )}
> >

View File

@ -22,7 +22,7 @@ export default function AppLayout() {
<LockProvider> <LockProvider>
<AlertsProvider> <AlertsProvider>
<NotificationProvider> <NotificationProvider>
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-dvh overflow-hidden bg-background">
<Sidebar <Sidebar
collapsed={collapsed} collapsed={collapsed}
onToggle={() => { onToggle={() => {
@ -41,7 +41,7 @@ export default function AppLayout() {
</Button> </Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1> <h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
</div> </div>
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto mobile-scale">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react'; import { useState, useMemo, useRef, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react'; import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -19,6 +20,7 @@ import LocationForm from './LocationForm';
export default function LocationsPage() { export default function LocationsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDesktop = useMediaQuery('(min-width: 1024px)');
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null); const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@ -285,10 +287,10 @@ export default function LocationsPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1> <h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight">Locations</h1>
<div className="flex-1 min-w-0"> <div className="w-full md:flex-1 md:w-auto min-w-0 order-last md:order-none">
<CategoryFilterBar <CategoryFilterBar
categories={orderedCategories} categories={orderedCategories}
activeFilters={activeFilters} activeFilters={activeFilters}
@ -305,8 +307,7 @@ export default function LocationsPage() {
</div> </div>
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add location"> <Button onClick={() => setShowForm(true)} size="sm" aria-label="Add location">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Location</span>
Add Location
</Button> </Button>
</div> </div>
@ -320,7 +321,7 @@ export default function LocationsPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full' panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`} }`}
> >
<div className="px-6 pb-6 pt-5"> <div className="px-4 md:px-6 pb-6 pt-5">
{isLoading ? ( {isLoading ? (
<EntityTable<Location> <EntityTable<Location>
columns={columns} columns={columns}
@ -357,26 +358,37 @@ export default function LocationsPage() {
sortDir={sortDir} sortDir={sortDir}
onSort={handleSort} onSort={handleSort}
visibilityMode={visibilityMode} visibilityMode={visibilityMode}
mobileCardRender={(location) => (
<div className={`rounded-lg border p-3 transition-colors ${selectedLocationId === location.id ? 'border-accent/40 bg-accent/5' : 'border-border bg-card hover:bg-card-elevated'}`}>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm truncate flex-1">{location.name}</span>
{location.category && <span className="text-xs text-muted-foreground">{location.category}</span>}
</div>
{location.address && (
<p className="text-xs text-muted-foreground truncate">{location.address}</p>
)}
</div>
)}
/> />
)} )}
</div> </div>
</div> </div>
{/* Detail panel (desktop) */} {/* Detail panel (desktop) */}
<div {panelOpen && isDesktop && (
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${ <div
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0' className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
}`} >
> {renderPanel()}
{renderPanel()} </div>
</div> )}
</div> </div>
</div> </div>
{/* Mobile detail panel overlay */} {/* Mobile detail panel overlay */}
{panelOpen && selectedLocation && ( {panelOpen && selectedLocation && !isDesktop && (
<div <div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm" className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSelectedLocationId(null)} onClick={() => setSelectedLocationId(null)}
> >
<div <div

View File

@ -162,7 +162,7 @@ export default function NotificationsPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Page header */} {/* Page header */}
<div className="border-b bg-card px-6 h-16 flex items-center justify-between shrink-0"> <div className="border-b bg-card px-4 md:px-6 h-16 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-accent" aria-hidden="true" /> <Bell className="h-5 w-5 text-accent" aria-hidden="true" />
<h1 className="text-xl font-semibold font-heading">Notifications</h1> <h1 className="text-xl font-semibold font-heading">Notifications</h1>
@ -227,7 +227,7 @@ export default function NotificationsPage() {
key={notification.id} key={notification.id}
onClick={() => handleNotificationClick(notification)} onClick={() => handleNotificationClick(notification)}
className={cn( className={cn(
'flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer', 'flex items-start gap-3 px-4 md:px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer',
!notification.is_read && 'bg-card' !notification.is_read && 'bg-card'
)} )}
> >
@ -316,7 +316,7 @@ export default function NotificationsPage() {
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })} {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</span> </span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0.5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
{!notification.is_read && ( {!notification.is_read && (
<button <button
onClick={(e) => { e.stopPropagation(); handleMarkRead(notification.id); }} onClick={(e) => { e.stopPropagation(); handleMarkRead(notification.id); }}

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react'; import { useState, useMemo, useRef, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react'; import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@ -214,6 +215,7 @@ const panelFields: PanelField[] = [
export default function PeoplePage() { export default function PeoplePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const tableContainerRef = useRef<HTMLDivElement>(null); const tableContainerRef = useRef<HTMLDivElement>(null);
const isDesktop = useMediaQuery('(min-width: 1024px)');
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null); const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@ -555,9 +557,9 @@ export default function PeoplePage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1> <h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight">People</h1>
<div className="flex-1 min-w-0"> <div className="w-full md:flex-1 md:w-auto min-w-0 order-last md:order-none">
<CategoryFilterBar <CategoryFilterBar
activeFilters={activeFilters} activeFilters={activeFilters}
pinnedLabel="Favourites" pinnedLabel="Favourites"
@ -587,8 +589,7 @@ export default function PeoplePage() {
aria-label="Add person" aria-label="Add person"
className="rounded-r-none" className="rounded-r-none"
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Person</span>
Add Person
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -622,7 +623,7 @@ export default function PeoplePage() {
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col">
{/* Stat bar */} {/* Stat bar */}
{!isLoading && people.length > 0 && ( {!isLoading && people.length > 0 && (
<div className="px-6 pt-4 pb-2 flex items-start gap-6 shrink-0"> <div className="px-4 md:px-6 pt-4 pb-2 flex items-start gap-6 shrink-0">
<div className="flex gap-6 shrink-0"> <div className="flex gap-6 shrink-0">
<StatCounter <StatCounter
icon={Users} icon={Users}
@ -665,7 +666,7 @@ export default function PeoplePage() {
{/* Pending requests */} {/* Pending requests */}
{hasRequests && ( {hasRequests && (
<div className="px-6 pb-3"> <div className="px-4 md:px-6 pb-3">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium"> <span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
Pending Requests Pending Requests
@ -706,7 +707,7 @@ export default function PeoplePage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full' panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`} }`}
> >
<div className="px-6 pb-6"> <div className="px-4 md:px-6 pb-6">
{isLoading ? ( {isLoading ? (
<EntityTable<Person> <EntityTable<Person>
columns={columns} columns={columns}
@ -745,26 +746,38 @@ export default function PeoplePage() {
sortDir={sortDir} sortDir={sortDir}
onSort={handleSort} onSort={handleSort}
visibilityMode={visibilityMode} visibilityMode={visibilityMode}
mobileCardRender={(person) => (
<div className={`rounded-lg border p-3 transition-colors ${selectedPersonId === person.id ? 'border-accent/40 bg-accent/5' : 'border-border bg-card hover:bg-card-elevated'}`}>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm truncate flex-1">{person.name}</span>
{person.category && <span className="text-xs text-muted-foreground">{person.category}</span>}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{person.email && <span className="truncate">{person.email}</span>}
{person.phone && <span>{person.phone}</span>}
</div>
</div>
)}
/> />
)} )}
</div> </div>
</div> </div>
{/* Detail panel (desktop) */} {/* Detail panel (desktop) */}
<div {panelOpen && isDesktop && (
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${ <div
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0' className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
}`} >
> {renderPanel()}
{renderPanel()} </div>
</div> )}
</div> </div>
</div> </div>
{/* Mobile detail panel overlay */} {/* Mobile detail panel overlay */}
{panelOpen && selectedPerson && ( {panelOpen && selectedPerson && !isDesktop && (
<div <div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm" className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSelectedPersonId(null)} onClick={() => setSelectedPersonId(null)}
> >
<div <div

View File

@ -2,6 +2,7 @@ import {
DndContext, DndContext,
closestCorners, closestCorners,
PointerSensor, PointerSensor,
TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
@ -53,7 +54,7 @@ function KanbanColumn({
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
className={`flex-1 min-w-[200px] rounded-lg border transition-colors duration-150 ${ className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50' isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
}`} }`}
> >
@ -152,7 +153,8 @@ export default function KanbanBoard({
onBackToAllTasks, onBackToAllTasks,
}: KanbanBoardProps) { }: KanbanBoardProps) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ,
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } })
); );
// Subtask view is driven by kanbanParentTask (decoupled from selected task) // Subtask view is driven by kanbanParentTask (decoupled from selected task)

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -257,6 +258,8 @@ export default function ProjectDetail() {
} }
}, [topLevelTasks, sortMode, sortSubtasks]); }, [topLevelTasks, sortMode, sortSubtasks]);
const isDesktop = useMediaQuery('(min-width: 1024px)');
const selectedTask = useMemo(() => { const selectedTask = useMemo(() => {
if (!selectedTaskId) return null; if (!selectedTaskId) return null;
// Search top-level and subtasks // Search top-level and subtasks
@ -345,13 +348,13 @@ export default function ProjectDetail() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <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"> <div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}> <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
<h1 className="font-heading text-2xl font-bold tracking-tight flex-1">Loading...</h1> <h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight flex-1">Loading...</h1>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-5"> <div className="flex-1 overflow-y-auto px-4 md:px-6 py-5">
<ListSkeleton rows={4} /> <ListSkeleton rows={4} />
</div> </div>
</div> </div>
@ -375,14 +378,14 @@ export default function ProjectDetail() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-2 md:gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}> <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
<h1 className="font-heading text-2xl font-bold tracking-tight flex-1 truncate"> <h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight flex-1 truncate min-w-0">
{project.name} {project.name}
</h1> </h1>
<Badge className={statusColors[project.status]}> <Badge className={`shrink-0 hidden sm:inline-flex ${statusColors[project.status]}`}>
{statusLabels[project.status]} {statusLabels[project.status]}
</Badge> </Badge>
<Button <Button
@ -390,34 +393,32 @@ export default function ProjectDetail() {
size="icon" size="icon"
onClick={() => toggleTrackMutation.mutate()} onClick={() => toggleTrackMutation.mutate()}
disabled={toggleTrackMutation.isPending} disabled={toggleTrackMutation.isPending}
className={project.is_tracked ? 'text-accent' : 'text-muted-foreground'} className={`shrink-0 ${project.is_tracked ? 'text-accent' : 'text-muted-foreground'}`}
title={project.is_tracked ? 'Untrack project' : 'Track project'} title={project.is_tracked ? 'Untrack project' : 'Track project'}
> >
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} /> <Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setShowProjectForm(true)}> <Button variant="outline" size="sm" className="shrink-0" onClick={() => setShowProjectForm(true)}>
<Pencil className="mr-2 h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
Edit
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-destructive hover:bg-destructive/10" className="shrink-0 text-destructive hover:bg-destructive/10"
onClick={() => { onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return; if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate(); deleteProjectMutation.mutate();
}} }}
disabled={deleteProjectMutation.isPending} disabled={deleteProjectMutation.isPending}
> >
<Trash2 className="mr-2 h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
Delete
</Button> </Button>
</div> </div>
{/* Content area */} {/* Content area */}
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col">
{/* Summary section - scrolls with left panel on small, fixed on large */} {/* Summary section - scrolls with left panel on small, fixed on large */}
<div className="px-6 py-5 space-y-5 shrink-0 overflow-y-auto max-h-[50vh] lg:max-h-none lg:overflow-visible"> <div className="px-4 md:px-6 py-5 space-y-5 shrink-0 overflow-y-auto max-h-[50vh] lg:max-h-none lg:overflow-visible">
{/* Description */} {/* Description */}
{project.description && ( {project.description && (
<p className="text-sm text-muted-foreground">{project.description}</p> <p className="text-sm text-muted-foreground">{project.description}</p>
@ -426,7 +427,7 @@ export default function ProjectDetail() {
{/* Project Summary Card */} {/* Project Summary Card */}
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-center gap-6"> <div className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-baseline justify-between mb-2"> <div className="flex items-baseline justify-between mb-2">
<span className="text-sm text-muted-foreground">Overall Progress</span> <span className="text-sm text-muted-foreground">Overall Progress</span>
@ -444,7 +445,7 @@ export default function ProjectDetail() {
{completedTasks} of {totalTasks} tasks completed {completedTasks} of {totalTasks} tasks completed
</p> </p>
</div> </div>
<div className="w-px h-16 bg-border" /> <div className="hidden sm:block w-px h-16 bg-border" />
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
<div className="text-center"> <div className="text-center">
<div className="p-1.5 rounded-md bg-blue-500/10 mx-auto w-fit mb-1"> <div className="p-1.5 rounded-md bg-blue-500/10 mx-auto w-fit mb-1">
@ -490,7 +491,7 @@ export default function ProjectDetail() {
</div> </div>
{/* Task list header + view controls */} {/* Task list header + view controls */}
<div className="px-6 pb-3 flex items-center justify-between shrink-0"> <div className="px-4 md:px-6 pb-3 flex items-center justify-between flex-wrap gap-2 shrink-0">
<h2 className="font-heading text-lg font-semibold">Tasks</h2> <h2 className="font-heading text-lg font-semibold">Tasks</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* View toggle */} {/* View toggle */}
@ -544,7 +545,7 @@ export default function ProjectDetail() {
<div className="flex-1 overflow-hidden flex"> <div className="flex-1 overflow-hidden flex">
{/* Left panel: task list or kanban */} {/* Left panel: task list or kanban */}
<div className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${selectedTaskId ? 'w-full lg:w-[55%]' : 'w-full'}`}> <div className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${selectedTaskId ? 'w-full lg:w-[55%]' : 'w-full'}`}>
<div className="px-6 pb-6"> <div className="px-4 md:px-6 pb-6">
{topLevelTasks.length === 0 ? ( {topLevelTasks.length === 0 ? (
<EmptyState <EmptyState
icon={ListChecks} icon={ListChecks}
@ -599,7 +600,7 @@ export default function ProjectDetail() {
/> />
{/* Expanded subtasks */} {/* Expanded subtasks */}
{isExpanded && hasSubtasks && ( {isExpanded && hasSubtasks && (
<div className="ml-10 mt-0.5 space-y-0.5"> <div className="ml-5 sm:ml-10 mt-0.5 space-y-0.5">
{task.subtasks.map((subtask) => ( {task.subtasks.map((subtask) => (
<TaskRow <TaskRow
key={subtask.id} key={subtask.id}
@ -630,29 +631,29 @@ export default function ProjectDetail() {
</div> </div>
</div> </div>
{/* Right panel: task detail (hidden on small screens) */} {/* Right panel: task detail (desktop only) */}
<div {selectedTaskId && isDesktop && (
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card ${ <div
selectedTaskId ? 'hidden lg:flex lg:w-[45%]' : 'w-0 opacity-0 border-l-0' className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card flex w-[45%]"
}`} >
> <div className="flex-1 overflow-hidden min-w-[360px]">
<div className="flex-1 overflow-hidden min-w-[360px]"> <TaskDetailPanel
<TaskDetailPanel task={selectedTask}
task={selectedTask} projectId={parseInt(id!)}
projectId={parseInt(id!)} onDelete={handleDeleteTask}
onDelete={handleDeleteTask} onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onAddSubtask={(parentId) => openTaskForm(null, parentId)} onClose={() => setSelectedTaskId(null)}
onClose={() => setSelectedTaskId(null)} onSelectTask={setSelectedTaskId}
onSelectTask={setSelectedTaskId} />
/> </div>
</div> </div>
</div> )}
</div> </div>
</div> </div>
{/* Mobile: show detail panel as overlay when task selected on small screens */} {/* Mobile: show detail panel as overlay when task selected on small screens */}
{selectedTaskId && selectedTask && ( {selectedTaskId && selectedTask && !isDesktop && (
<div className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"> <div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"> <div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-medium text-muted-foreground">Task Details</span> <span className="text-sm font-medium text-muted-foreground">Task Details</span>

View File

@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { GridSkeleton } from '@/components/ui/skeleton'; import { GridSkeleton } from '@/components/ui/skeleton';
@ -70,10 +71,19 @@ export default function ProjectsPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1> <h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight">Projects</h1>
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4"> <Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="h-8 text-sm w-auto pr-8 md:hidden"
>
{statusFilters.map((sf) => (
<option key={sf.value} value={sf.value}>{sf.label}</option>
))}
</Select>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden ml-4">
{statusFilters.map((sf) => ( {statusFilters.map((sf) => (
<button <button
key={sf.value} key={sf.value}
@ -101,50 +111,49 @@ export default function ProjectsPage() {
placeholder="Search..." placeholder="Search..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm" className="w-32 sm:w-52 h-8 pl-8 text-sm"
/> />
</div> </div>
<Button onClick={() => setShowForm(true)} size="sm"> <Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">New Project</span>
New Project
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-5"> <div className="flex-1 overflow-y-auto px-4 md:px-6 py-5">
{/* Summary stats */} {/* Summary stats */}
{!isLoading && projects.length > 0 && ( {!isLoading && projects.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5"> <div className="grid gap-1.5 md:gap-2.5 grid-cols-3 mb-5">
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
<div className="p-1.5 rounded-md bg-blue-500/10"> <div className="p-1.5 rounded-md bg-blue-500/10 hidden sm:block">
<Layers className="h-4 w-4 text-blue-400" /> <Layers className="h-4 w-4 text-blue-400" />
</div> </div>
<div> <div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Total</p> <p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">Total</p>
<p className="font-heading text-xl font-bold tabular-nums">{projects.length}</p> <p className="font-heading text-lg md:text-xl font-bold tabular-nums">{projects.length}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
<div className="p-1.5 rounded-md bg-purple-500/10"> <div className="p-1.5 rounded-md bg-purple-500/10 hidden sm:block">
<PlayCircle className="h-4 w-4 text-purple-400" /> <PlayCircle className="h-4 w-4 text-purple-400" />
</div> </div>
<div> <div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">In Progress</p> <p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">In Progress</p>
<p className="font-heading text-xl font-bold tabular-nums">{inProgressCount}</p> <p className="font-heading text-lg md:text-xl font-bold tabular-nums">{inProgressCount}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
<div className="p-1.5 rounded-md bg-green-500/10"> <div className="p-1.5 rounded-md bg-green-500/10 hidden sm:block">
<CheckCircle2 className="h-4 w-4 text-green-400" /> <CheckCircle2 className="h-4 w-4 text-green-400" />
</div> </div>
<div> <div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Completed</p> <p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">Completed</p>
<p className="font-heading text-xl font-bold tabular-nums">{completedCount}</p> <p className="font-heading text-lg md:text-xl font-bold tabular-nums">{completedCount}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -484,7 +484,7 @@ export default function TaskDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive shrink-0" className="h-5 w-5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive shrink-0"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteSubtask(subtask.id, subtask.title); handleDeleteSubtask(subtask.id, subtask.title);
@ -527,7 +527,7 @@ export default function TaskDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive" className="h-5 w-5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
onClick={() => { onClick={() => {
if (!window.confirm('Delete this comment?')) return; if (!window.confirm('Delete this comment?')) return;
deleteCommentMutation.mutate(comment.id); deleteCommentMutation.mutate(comment.id);

View File

@ -52,7 +52,7 @@ export default function TaskRow({
return ( return (
<div <div
className={`relative flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors duration-150 ${ className={`relative flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-2 rounded-lg cursor-pointer transition-colors duration-150 ${
isSelected isSelected
? 'bg-accent/5 border-l-2 border-accent' ? 'bg-accent/5 border-l-2 border-accent'
: 'border-l-2 border-transparent hover:bg-card-elevated' : 'border-l-2 border-transparent hover:bg-card-elevated'
@ -108,18 +108,18 @@ export default function TaskRow({
</span> </span>
{/* Metadata columns */} {/* Metadata columns */}
<Badge className={`text-[9px] px-1.5 py-0.5 shrink-0 w-16 text-center ${taskStatusColors[task.status]}`}> <Badge className={`text-[9px] px-1.5 py-0.5 shrink-0 w-16 text-center hidden sm:inline-flex ${taskStatusColors[task.status]}`}>
{task.status.replace('_', ' ')} {task.status.replace('_', ' ')}
</Badge> </Badge>
<Badge <Badge
className={`text-[9px] px-1.5 py-0.5 rounded-full shrink-0 w-14 text-center ${priorityColors[task.priority]}`} className={`text-[9px] px-1.5 py-0.5 rounded-full shrink-0 w-14 text-center hidden sm:inline-flex ${priorityColors[task.priority]}`}
> >
{task.priority} {task.priority}
</Badge> </Badge>
<span <span
className={`text-[11px] shrink-0 tabular-nums w-12 text-right ${ className={`text-[11px] shrink-0 tabular-nums w-12 text-right hidden sm:block ${
task.due_date task.due_date
? isOverdue ? 'text-red-400' : 'text-muted-foreground' ? isOverdue ? 'text-red-400' : 'text-muted-foreground'
: 'text-transparent' : 'text-transparent'
@ -128,12 +128,22 @@ export default function TaskRow({
{task.due_date ? format(parseISO(task.due_date), 'MMM d') : '—'} {task.due_date ? format(parseISO(task.due_date), 'MMM d') : '—'}
</span> </span>
<span className={`text-[11px] shrink-0 tabular-nums w-8 text-right ${ <span className={`text-[11px] shrink-0 tabular-nums w-8 text-right hidden sm:block ${
hasSubtasks ? 'text-muted-foreground' : 'text-transparent' hasSubtasks ? 'text-muted-foreground' : 'text-transparent'
}`}> }`}>
{hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'} {hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'}
</span> </span>
{/* Mobile-only: compact priority dot + overdue indicator */}
<div className="flex items-center gap-1.5 sm:hidden shrink-0">
<div className={`h-2 w-2 rounded-full ${
task.priority === 'high' ? 'bg-red-400' :
task.priority === 'medium' ? 'bg-yellow-400' :
task.priority === 'low' ? 'bg-green-400' : 'bg-gray-500'
}`} />
{isOverdue && <span className="text-[10px] text-red-400 tabular-nums">{task.due_date ? format(parseISO(task.due_date), 'M/d') : ''}</span>}
</div>
{/* Subtask progress bar */} {/* Subtask progress bar */}
{hasSubtasks && ( {hasSubtasks && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-secondary/50 rounded-full overflow-hidden"> <div className="absolute bottom-0 left-0 right-0 h-[2px] bg-secondary/50 rounded-full overflow-hidden">

View File

@ -73,14 +73,14 @@ export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) {
return ( return (
<div <div
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md transition-colors duration-150', 'flex items-start md:items-center gap-2 md:gap-3 px-3 py-2 rounded-md transition-colors duration-150',
'hover:bg-card-elevated', 'hover:bg-card-elevated',
reminder.is_dismissed && 'opacity-50' reminder.is_dismissed && 'opacity-50'
)} )}
> >
<Bell <Bell
className={cn( className={cn(
'h-4 w-4 shrink-0', 'h-4 w-4 shrink-0 mt-0.5 md:mt-0',
isOverdue isOverdue
? 'text-red-400' ? 'text-red-400'
: reminder.is_dismissed : reminder.is_dismissed
@ -89,78 +89,86 @@ export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) {
)} )}
/> />
<span {/* Content wrapper — stacks on mobile, inline on desktop */}
className={cn( <div className="flex-1 min-w-0 flex flex-col md:flex-row md:items-center gap-1 md:gap-3">
'text-sm font-medium truncate flex-1 min-w-0 cursor-pointer',
reminder.is_dismissed && 'line-through text-muted-foreground'
)}
onClick={() => onEdit(reminder)}
>
{reminder.title}
</span>
{reminder.recurrence_rule && (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-purple-500/15 text-purple-400 shrink-0">
{recurrenceLabels[reminder.recurrence_rule] || reminder.recurrence_rule}
</span>
)}
{remindDate && (
<span <span
className={cn( className={cn(
'text-[11px] shrink-0', 'text-sm font-medium truncate cursor-pointer md:flex-1 md:min-w-0',
isOverdue ? 'text-red-400' : isDueToday ? 'text-yellow-400' : 'text-muted-foreground' reminder.is_dismissed && 'line-through text-muted-foreground'
)} )}
onClick={() => onEdit(reminder)}
> >
{format(remindDate, 'MMM d, h:mm a')} {reminder.title}
</span> </span>
)}
{!reminder.is_dismissed && ( <div className="flex items-center gap-1.5 flex-wrap">
{reminder.recurrence_rule && (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-purple-500/15 text-purple-400 shrink-0">
{recurrenceLabels[reminder.recurrence_rule] || reminder.recurrence_rule}
</span>
)}
{remindDate && (
<span
className={cn(
'text-[11px] shrink-0',
isOverdue ? 'text-red-400' : isDueToday ? 'text-yellow-400' : 'text-muted-foreground'
)}
>
{format(remindDate, 'MMM d, h:mm a')}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-0.5 shrink-0">
{!reminder.is_dismissed && (
<Button
variant="ghost"
size="icon"
onClick={() => dismissMutation.mutate()}
disabled={dismissMutation.isPending}
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
aria-label="Dismiss reminder"
>
<BellOff className="h-3 w-3" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => dismissMutation.mutate()} onClick={() => onEdit(reminder)}
disabled={dismissMutation.isPending} className="h-7 w-7"
className="h-7 w-7 shrink-0 hover:bg-orange-500/10 hover:text-orange-400" aria-label="Edit reminder"
aria-label="Dismiss reminder"
> >
<BellOff className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</Button> </Button>
)}
<Button {confirmingDelete ? (
variant="ghost" <Button
size="icon" variant="ghost"
onClick={() => onEdit(reminder)} aria-label="Confirm delete"
className="h-7 w-7 shrink-0" onClick={handleDelete}
aria-label="Edit reminder" disabled={deleteMutation.isPending}
> className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
<Pencil className="h-3 w-3" /> >
</Button> Sure?
</Button>
{confirmingDelete ? ( ) : (
<Button <Button
variant="ghost" variant="ghost"
aria-label="Confirm delete" size="icon"
onClick={handleDelete} aria-label="Delete reminder"
disabled={deleteMutation.isPending} onClick={handleDelete}
className="h-7 shrink-0 px-2 bg-destructive/20 text-destructive text-[11px] font-medium" disabled={deleteMutation.isPending}
> className="h-7 w-7 hover:bg-destructive/10 hover:text-destructive"
Sure? >
</Button> <Trash2 className="h-3 w-3" />
) : ( </Button>
<Button )}
variant="ghost" </div>
size="icon"
aria-label="Delete reminder"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="h-7 w-7 shrink-0 hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div> </div>
); );
} }

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react'; import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -6,6 +7,7 @@ import { isPast, isToday, parseISO } from 'date-fns';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Reminder } from '@/types'; import type { Reminder } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
@ -23,6 +25,8 @@ type StatusFilter = (typeof statusFilters)[number]['value'];
export default function RemindersPage() { export default function RemindersPage() {
const location = useLocation(); const location = useLocation();
const isDesktop = useMediaQuery('(min-width: 1024px)');
// Panel state // Panel state
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null); const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed'); const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
@ -99,10 +103,19 @@ export default function RemindersPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1> <h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight">Reminders</h1>
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4"> <Select
value={filter}
onChange={(e) => setFilter(e.target.value as typeof filter)}
className="h-8 text-sm w-auto pr-8 md:hidden"
>
{statusFilters.map((sf) => (
<option key={sf.value} value={sf.value}>{sf.label}</option>
))}
</Select>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden ml-4">
{statusFilters.map((sf) => ( {statusFilters.map((sf) => (
<button <button
key={sf.value} key={sf.value}
@ -125,19 +138,18 @@ export default function RemindersPage() {
<div className="flex-1" /> <div className="flex-1" />
<div className="relative"> <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" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder="Search..." placeholder="Search..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm ring-inset" className="w-28 sm:w-52 h-8 pl-8 text-sm ring-inset"
/> />
</div> </div>
<Button onClick={handleCreateNew} size="sm"> <Button onClick={handleCreateNew} size="sm" className="shrink-0">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Reminder</span>
Add Reminder
</Button> </Button>
</div> </div>
@ -148,46 +160,46 @@ export default function RemindersPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full' panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`} }`}
> >
<div className="px-6 py-5"> <div className="px-4 md:px-6 py-5">
{/* Summary stats */} {/* Summary stats */}
{!isLoading && reminders.length > 0 && ( {!isLoading && reminders.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5"> <div className="grid gap-1.5 md:gap-2.5 grid-cols-3 mb-5">
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
<div className="p-1.5 rounded-md bg-orange-500/10"> <div className="p-1.5 rounded-md bg-orange-500/10 hidden sm:block">
<Bell className="h-4 w-4 text-orange-400" /> <Bell className="h-4 w-4 text-orange-400" />
</div> </div>
<div> <div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> <p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">
Active Active
</p> </p>
<p className="font-heading text-xl font-bold tabular-nums">{activeCount}</p> <p className="font-heading text-lg md:text-xl font-bold tabular-nums">{activeCount}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
<div className="p-1.5 rounded-md bg-red-500/10"> <div className="p-1.5 rounded-md bg-red-500/10 hidden sm:block">
<AlertCircle className="h-4 w-4 text-red-400" /> <AlertCircle className="h-4 w-4 text-red-400" />
</div> </div>
<div> <div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> <p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">
Overdue Overdue
</p> </p>
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p> <p className="font-heading text-lg md:text-xl font-bold tabular-nums">{overdueCount}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
<div className="p-1.5 rounded-md bg-gray-500/10"> <div className="p-1.5 rounded-md bg-gray-500/10 hidden sm:block">
<BellOff className="h-4 w-4 text-gray-400" /> <BellOff className="h-4 w-4 text-gray-400" />
</div> </div>
<div> <div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> <p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">
Dismissed Dismissed
</p> </p>
<p className="font-heading text-xl font-bold tabular-nums">{dismissedCount}</p> <p className="font-heading text-lg md:text-xl font-bold tabular-nums">{dismissedCount}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -207,24 +219,24 @@ export default function RemindersPage() {
</div> </div>
{/* Detail panel (desktop) */} {/* Detail panel (desktop) */}
<div {panelOpen && isDesktop && (
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${ <div
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0' className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
}`} >
> <ReminderDetailPanel
<ReminderDetailPanel reminder={panelMode === 'view' ? selectedReminder : null}
reminder={panelMode === 'view' ? selectedReminder : null} isCreating={panelMode === 'create'}
isCreating={panelMode === 'create'} onClose={handlePanelClose}
onClose={handlePanelClose} onSaved={panelMode === 'create' ? handlePanelClose : undefined}
onSaved={panelMode === 'create' ? handlePanelClose : undefined} />
/> </div>
</div> )}
</div> </div>
{/* Mobile detail panel overlay */} {/* Mobile detail panel overlay */}
{panelOpen && ( {panelOpen && !isDesktop && (
<div <div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm" className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose} onClick={handlePanelClose}
> >
<div <div

View File

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

View File

@ -146,56 +146,57 @@ export default function CategoryFilterBar({
}; };
return ( return (
<div className="flex items-center gap-2 overflow-x-auto"> <div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-2">
{/* All pill */} {/* Top row: pills + search */}
<button <div className="flex items-center gap-2 overflow-x-auto min-w-0 flex-1">
type="button" {/* All pill */}
onClick={onToggleAll}
aria-label="Show all"
className={pillBase}
style={isAllActive ? activePillStyle : undefined}
>
<span
className={
isAllActive ? '' : 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}
>
All
</span>
</button>
{/* Pinned pill */}
<button
type="button"
onClick={onTogglePinned}
aria-label={`Toggle ${pinnedLabel}`}
className={pillBase}
style={showPinned ? activePillStyle : undefined}
>
<span className={showPinned ? '' : 'text-muted-foreground hover:text-foreground'}>
{pinnedLabel}
</span>
</button>
{/* Extra pinned filters (e.g. "Umbral") */}
{extraPinnedFilters.map((epf) => (
<button <button
key={epf.label}
type="button" type="button"
onClick={epf.onToggle} onClick={onToggleAll}
aria-label={`Filter by ${epf.label}`} aria-label="Show all"
className={pillBase} className={pillBase}
style={epf.isActive ? activePillStyle : undefined} style={isAllActive ? activePillStyle : undefined}
> >
<span className={epf.isActive ? '' : 'text-muted-foreground hover:text-foreground'}> <span
{epf.label} className={
isAllActive ? '' : 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}
>
All
</span> </span>
</button> </button>
))}
{/* Categories pill + expandable chips */} {/* Pinned pill */}
{categories.length > 0 && ( <button
<> type="button"
onClick={onTogglePinned}
aria-label={`Toggle ${pinnedLabel}`}
className={pillBase}
style={showPinned ? activePillStyle : undefined}
>
<span className={showPinned ? '' : 'text-muted-foreground hover:text-foreground'}>
{pinnedLabel}
</span>
</button>
{/* Extra pinned filters (e.g. "Umbral") */}
{extraPinnedFilters.map((epf) => (
<button
key={epf.label}
type="button"
onClick={epf.onToggle}
aria-label={`Filter by ${epf.label}`}
className={pillBase}
style={epf.isActive ? activePillStyle : undefined}
>
<span className={epf.isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
{epf.label}
</span>
</button>
))}
{/* Categories pill */}
{categories.length > 0 && (
<button <button
type="button" type="button"
onClick={() => setOtherOpen((p) => !p)} onClick={() => setOtherOpen((p) => !p)}
@ -207,78 +208,72 @@ export default function CategoryFilterBar({
Categories Categories
</span> </span>
</button> </button>
)}
<div {/* Search */}
className="flex items-center gap-1.5 overflow-x-auto transition-all duration-200 ease-out" <div className="flex-1" />
style={{ <div className="relative shrink-0">
maxWidth: otherOpen ? '100vw' : '0px', <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" />
opacity: otherOpen ? 1 : 0, <Input
overflow: 'hidden', ref={searchInputRef}
}} type="search"
> placeholder="Search..."
{/* "All" chip inside categories — non-draggable */} value={searchValue}
{onSelectAllCategories && ( onChange={(e) => onSearchChange(e.target.value)}
<button className="w-28 sm:w-52 h-8 pl-8 text-sm ring-inset"
type="button" aria-label="Search"
onClick={onSelectAllCategories} />
aria-label="Select all categories" </div>
aria-pressed={allCategoriesSelected}
className="px-2 py-1 text-xs font-medium rounded transition-colors duration-150 whitespace-nowrap shrink-0"
style={allCategoriesSelected ? activePillStyle : undefined}
>
<span
className={
allCategoriesSelected
? ''
: 'text-muted-foreground hover:text-foreground'
}
>
All
</span>
</button>
)}
{/* Draggable category chips */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={categories}
strategy={horizontalListSortingStrategy}
>
{categories.map((cat) => (
<SortableCategoryChip
key={cat}
id={cat}
isActive={activeFilters.includes(cat)}
onToggle={() => onToggleCategory(cat)}
/>
))}
</SortableContext>
</DndContext>
</div>
</>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Search */}
<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)}
className="w-52 h-8 pl-8 text-sm ring-inset"
aria-label="Search"
/>
</div> </div>
{/* Expanded categories row — shows below on mobile, inline on desktop */}
{categories.length > 0 && otherOpen && (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0">
{/* "All" chip inside categories — non-draggable */}
{onSelectAllCategories && (
<button
type="button"
onClick={onSelectAllCategories}
aria-label="Select all categories"
aria-pressed={allCategoriesSelected}
className="px-2 py-1 text-xs font-medium rounded transition-colors duration-150 whitespace-nowrap shrink-0"
style={allCategoriesSelected ? activePillStyle : undefined}
>
<span
className={
allCategoriesSelected
? ''
: 'text-muted-foreground hover:text-foreground'
}
>
All
</span>
</button>
)}
{/* Draggable category chips */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={categories}
strategy={horizontalListSortingStrategy}
>
{categories.map((cat) => (
<SortableCategoryChip
key={cat}
id={cat}
isActive={activeFilters.includes(cat)}
onToggle={() => onToggleCategory(cat)}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
</div> </div>
); );
} }

View File

@ -27,7 +27,7 @@ export default function CopyableField({ value, icon: Icon, label }: CopyableFiel
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
aria-label={`Copy ${label || value}`} aria-label={`Copy ${label || value}`}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 p-0.5 rounded text-muted-foreground hover:text-foreground shrink-0" className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-150 p-0.5 rounded text-muted-foreground hover:text-foreground shrink-0"
> >
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button> </button>

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { VisibilityMode } from '@/hooks/useTableVisibility'; import type { VisibilityMode } from '@/hooks/useTableVisibility';
import { useMediaQuery } from '@/hooks/useMediaQuery';
export interface ColumnDef<T> { export interface ColumnDef<T> {
key: string; key: string;
@ -28,6 +29,7 @@ interface EntityTableProps<T extends { id: number }> {
onSort: (key: string) => void; onSort: (key: string) => void;
visibilityMode: VisibilityMode; visibilityMode: VisibilityMode;
loading?: boolean; loading?: boolean;
mobileCardRender?: (item: T) => React.ReactNode;
} }
const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all']; const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
@ -127,10 +129,51 @@ export function EntityTable<T extends { id: number }>({
onSort, onSort,
visibilityMode, visibilityMode,
loading = false, loading = false,
mobileCardRender,
}: EntityTableProps<T>) { }: EntityTableProps<T>) {
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode)); const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
const colCount = visibleColumns.length; const colCount = visibleColumns.length;
const showPinnedSection = showPinned && pinnedRows.length > 0; const showPinnedSection = showPinned && pinnedRows.length > 0;
const isMobile = useMediaQuery('(max-width: 767px)');
if (isMobile && mobileCardRender) {
return (
<div className="space-y-2">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-lg bg-card border border-border p-4 h-20" />
))
) : (
<>
{showPinnedSection && (
<>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground font-medium pt-2">{pinnedLabel}</p>
{pinnedRows.map((item) => (
<div key={item.id} onClick={() => onRowClick(item.id)} className="cursor-pointer" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(item.id); } }}>
{mobileCardRender(item)}
</div>
))}
</>
)}
{groups.map((group) => (
<React.Fragment key={group.label}>
{group.rows.length > 0 && (
<>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground font-medium pt-2">{group.label}</p>
{group.rows.map((item) => (
<div key={item.id} onClick={() => onRowClick(item.id)} className="cursor-pointer" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(item.id); } }}>
{mobileCardRender(item)}
</div>
))}
</>
)}
</React.Fragment>
))}
</>
)}
</div>
);
}
return ( return (
<div className="w-full"> <div className="w-full">

View File

@ -52,7 +52,6 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
await api.delete(`/todos/${todo.id}`); await api.delete(`/todos/${todo.id}`);
}, },
onMutate: async () => { onMutate: async () => {
// Optimistic removal
await queryClient.cancelQueries({ queryKey: ['todos'] }); await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']); const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old) => queryClient.setQueryData<Todo[]>(['todos'], (old) =>
@ -65,7 +64,6 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
toast.success('Todo deleted'); toast.success('Todo deleted');
}, },
onError: (_err, _vars, context) => { onError: (_err, _vars, context) => {
// Rollback on failure
if (context?.previous) { if (context?.previous) {
queryClient.setQueryData(['todos'], context.previous); queryClient.setQueryData(['todos'], context.previous);
} }
@ -87,7 +85,7 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
return ( return (
<div <div
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md transition-colors duration-150', 'flex items-start md:items-center gap-2 md:gap-3 px-3 py-2 rounded-md transition-colors duration-150',
'hover:bg-card-elevated', 'hover:bg-card-elevated',
todo.completed && 'opacity-50' todo.completed && 'opacity-50'
)} )}
@ -96,100 +94,108 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
checked={todo.completed} checked={todo.completed}
onChange={() => toggleMutation.mutate()} onChange={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending} disabled={toggleMutation.isPending}
className="mt-0.5 md:mt-0"
/> />
<span {/* Content wrapper — stacks on mobile, inline on desktop */}
className={cn( <div className="flex-1 min-w-0 flex flex-col md:flex-row md:items-center gap-1 md:gap-3">
'text-sm font-medium truncate flex-1 min-w-0 cursor-pointer', {/* Title row — always takes full width on mobile */}
todo.completed && 'line-through text-muted-foreground' <span
)} className={cn(
onClick={() => onEdit(todo)} 'text-sm font-medium truncate cursor-pointer md:flex-1 md:min-w-0',
> todo.completed && 'line-through text-muted-foreground'
{todo.title} )}
</span> onClick={() => onEdit(todo)}
>
{/* Inline pills */} {todo.title}
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded-full font-medium shrink-0',
priorityStyles[todo.priority]
)}
>
{todo.priority}
</span>
{todo.category && (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-blue-500/15 text-blue-400 shrink-0">
{todo.category}
</span> </span>
)}
{todo.recurrence_rule && ( {/* Metadata row — wraps on second line on mobile */}
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-purple-500/15 text-purple-400 shrink-0"> <div className="flex items-center gap-1.5 flex-wrap">
{recurrenceLabels[todo.recurrence_rule] || todo.recurrence_rule} <span
</span> className={cn(
)} 'text-[9px] px-1.5 py-0.5 rounded-full font-medium shrink-0',
priorityStyles[todo.priority]
{/* Date / time / reset info — right-aligned cluster */}
{showResetInfo ? (
<div className="flex items-center gap-1 text-[11px] text-purple-400 shrink-0">
<RefreshCw className="h-3 w-3" />
<span>
Resets {format(resetDate, 'EEE dd/MM')}
{nextDueDate && (
<> · Due {format(nextDueDate, 'dd/MM')}{todo.due_time ? ` ${todo.due_time.slice(0, 5)}` : ''}</>
)} )}
>
{todo.priority}
</span> </span>
</div>
) : ( {todo.category && (
<> <span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-blue-500/15 text-blue-400 shrink-0">
{dueDate && ( {todo.category}
<span </span>
className={cn( )}
'flex items-center gap-1 text-[11px] shrink-0',
isOverdue ? 'text-red-400' : isDueToday ? 'text-yellow-400' : 'text-muted-foreground' {todo.recurrence_rule && (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-purple-500/15 text-purple-400 shrink-0">
{recurrenceLabels[todo.recurrence_rule] || todo.recurrence_rule}
</span>
)}
{showResetInfo ? (
<div className="flex items-center gap-1 text-[11px] text-purple-400 shrink-0">
<RefreshCw className="h-3 w-3" />
<span>
Resets {format(resetDate, 'EEE dd/MM')}
{nextDueDate && (
<>{' \u00b7 '}Due {format(nextDueDate, 'dd/MM')}{todo.due_time ? ` ${todo.due_time.slice(0, 5)}` : ''}</>
)}
</span>
</div>
) : (
<>
{dueDate && (
<span
className={cn(
'flex items-center gap-1 text-[11px] shrink-0',
isOverdue ? 'text-red-400' : isDueToday ? 'text-yellow-400' : 'text-muted-foreground'
)}
>
{isOverdue ? <AlertCircle className="h-3 w-3" /> : <Calendar className="h-3 w-3" />}
{isOverdue ? 'Overdue \u00b7 ' : isDueToday ? 'Today \u00b7 ' : ''}
{format(dueDate, 'MMM d')}
</span>
)} )}
> {todo.due_time && (
{isOverdue ? <AlertCircle className="h-3 w-3" /> : <Calendar className="h-3 w-3" />} <span className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0">
{isOverdue ? 'Overdue · ' : isDueToday ? 'Today · ' : ''} <Clock className="h-3 w-3" />
{format(dueDate, 'MMM d')} {todo.due_time.slice(0, 5)}
</span> </span>
)}
</>
)} )}
{todo.due_time && ( </div>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0"> </div>
<Clock className="h-3 w-3" />
{todo.due_time.slice(0, 5)}
</span>
)}
</>
)}
{/* Actions */} {/* Actions */}
<Button variant="ghost" size="icon" onClick={() => onEdit(todo)} className="h-7 w-7 shrink-0" aria-label="Edit todo"> <div className="flex items-center gap-0.5 shrink-0">
<Pencil className="h-3 w-3" /> <Button variant="ghost" size="icon" onClick={() => onEdit(todo)} className="h-7 w-7" aria-label="Edit todo">
</Button> <Pencil className="h-3 w-3" />
{confirmingDelete ? (
<Button
variant="ghost"
aria-label="Confirm delete"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="h-7 shrink-0 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
>
Sure?
</Button> </Button>
) : ( {confirmingDelete ? (
<Button <Button
variant="ghost" variant="ghost"
size="icon" aria-label="Confirm delete"
aria-label="Delete todo" onClick={handleDelete}
onClick={handleDelete} disabled={deleteMutation.isPending}
disabled={deleteMutation.isPending} className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
className="h-7 w-7 shrink-0 hover:bg-destructive/10 hover:text-destructive" >
> Sure?
<Trash2 className="h-3 w-3" /> </Button>
</Button> ) : (
)} <Button
variant="ghost"
size="icon"
aria-label="Delete todo"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="h-7 w-7 hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div> </div>
); );
} }

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react'; import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -6,6 +7,7 @@ import api from '@/lib/api';
import type { Todo } from '@/types'; import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils'; import { isTodoOverdue } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
import { CategoryFilterBar } from '@/components/shared'; import { CategoryFilterBar } from '@/components/shared';
@ -24,6 +26,8 @@ const priorityFilters = [
export default function TodosPage() { export default function TodosPage() {
const location = useLocation(); const location = useLocation();
const isDesktop = useMediaQuery('(min-width: 1024px)');
// Panel state // Panel state
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null); const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed'); const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
@ -128,11 +132,20 @@ export default function TodosPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1> <h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight">Todos</h1>
{/* Priority filter */} {/* Priority filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4"> <Select
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value as typeof priorityFilter)}
className="h-8 text-sm w-auto pr-8 md:hidden"
>
{priorityFilters.map((pf) => (
<option key={pf.value} value={pf.value}>{pf.label}</option>
))}
</Select>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden ml-4">
{priorityFilters.map((pf) => ( {priorityFilters.map((pf) => (
<button <button
key={pf.value} key={pf.value}
@ -154,7 +167,7 @@ export default function TodosPage() {
</div> </div>
{/* Category filter bar (All + Completed + Categories with drag) */} {/* Category filter bar (All + Completed + Categories with drag) */}
<div className="flex-1 min-w-0"> <div className="w-full md:flex-1 md:w-auto min-w-0 order-last md:order-none">
<CategoryFilterBar <CategoryFilterBar
activeFilters={activeFilters} activeFilters={activeFilters}
pinnedLabel="Completed" pinnedLabel="Completed"
@ -171,8 +184,7 @@ export default function TodosPage() {
</div> </div>
<Button onClick={handleCreateNew} size="sm"> <Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Todo</span>
Add Todo
</Button> </Button>
</div> </div>
@ -183,46 +195,46 @@ export default function TodosPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full' panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`} }`}
> >
<div className="px-6 py-5"> <div className="px-4 md:px-6 py-5">
{/* Summary stats */} {/* Summary stats */}
{!isLoading && todos.length > 0 && ( {!isLoading && todos.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5"> <div className="grid gap-1.5 md:gap-2.5 grid-cols-3 mb-5">
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
<div className="p-1.5 rounded-md bg-blue-500/10"> <div className="p-1.5 rounded-md bg-blue-500/10 hidden sm:block">
<CheckSquare className="h-4 w-4 text-blue-400" /> <CheckSquare className="h-4 w-4 text-blue-400" />
</div> </div>
<div> <div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> <p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">
Open Open
</p> </p>
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p> <p className="font-heading text-lg md:text-xl font-bold tabular-nums">{totalCount}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
<div className="p-1.5 rounded-md bg-green-500/10"> <div className="p-1.5 rounded-md bg-green-500/10 hidden sm:block">
<CheckCircle2 className="h-4 w-4 text-green-400" /> <CheckCircle2 className="h-4 w-4 text-green-400" />
</div> </div>
<div> <div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> <p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">
Completed Completed
</p> </p>
<p className="font-heading text-xl font-bold tabular-nums">{completedCount}</p> <p className="font-heading text-lg md:text-xl font-bold tabular-nums">{completedCount}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
<div className="p-1.5 rounded-md bg-red-500/10"> <div className="p-1.5 rounded-md bg-red-500/10 hidden sm:block">
<AlertCircle className="h-4 w-4 text-red-400" /> <AlertCircle className="h-4 w-4 text-red-400" />
</div> </div>
<div> <div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> <p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">
Overdue Overdue
</p> </p>
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p> <p className="font-heading text-lg md:text-xl font-bold tabular-nums">{overdueCount}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -242,24 +254,24 @@ export default function TodosPage() {
</div> </div>
{/* Detail panel (desktop) */} {/* Detail panel (desktop) */}
<div {panelOpen && isDesktop && (
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${ <div
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0' className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
}`} >
> <TodoDetailPanel
<TodoDetailPanel todo={panelMode === 'view' ? selectedTodo : null}
todo={panelMode === 'view' ? selectedTodo : null} isCreating={panelMode === 'create'}
isCreating={panelMode === 'create'} onClose={handlePanelClose}
onClose={handlePanelClose} onSaved={panelMode === 'create' ? handlePanelClose : undefined}
onSaved={panelMode === 'create' ? handlePanelClose : undefined} />
/> </div>
</div> )}
</div> </div>
{/* Mobile detail panel overlay */} {/* Mobile detail panel overlay */}
{panelOpen && ( {panelOpen && !isDesktop && (
<div <div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm" className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose} onClick={handlePanelClose}
> >
<div <div

View File

@ -18,7 +18,7 @@ const buttonVariants = cva(
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3', sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8', lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10', icon: 'h-10 w-10 min-h-[44px] min-w-[44px] md:min-h-0 md:min-w-0',
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -2,6 +2,7 @@ import * as React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useMediaQuery } from '@/hooks/useMediaQuery';
// ── Browser detection (stable — checked once at module load) ── // ── Browser detection (stable — checked once at module load) ──
@ -127,6 +128,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(); const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }); const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 });
const isMobile = useMediaQuery('(max-width: 767px)');
React.useImperativeHandle(ref, () => triggerRef.current!); React.useImperativeHandle(ref, () => triggerRef.current!);
@ -324,8 +326,8 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
<div <div
ref={popupRef} ref={popupRef}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 60 }} style={isMobile ? { position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 60 } : { position: 'fixed', top: pos.top, left: pos.left, zIndex: 60 }}
className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in" className={isMobile ? 'w-full rounded-t-lg border border-input bg-card shadow-lg animate-fade-in pb-[env(safe-area-inset-bottom)]' : 'w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in'}
> >
{/* Month/Year nav */} {/* Month/Year nav */}
<div className="flex items-center justify-between px-3 pt-3 pb-2"> <div className="flex items-center justify-between px-3 pt-3 pb-2">

View File

@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}
ref={ref} ref={ref}

View File

@ -10,7 +10,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<div className="relative"> <div className="relative">
<select <select
className={cn( className={cn(
'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500', 'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500',
className className
)} )}
ref={ref} ref={ref}

View File

@ -60,7 +60,7 @@ const Sheet: React.FC<SheetProps> = ({ open, onOpenChange, children }) => {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className={cn( className={cn(
'fixed right-0 top-0 h-full w-full max-w-[540px] transition-transform duration-350', 'fixed right-0 top-0 h-full w-full sm:max-w-[540px] transition-transform duration-350',
visible ? 'translate-x-0' : 'translate-x-full' visible ? 'translate-x-0' : 'translate-x-full'
)} )}
style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }} style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}

View File

@ -0,0 +1,16 @@
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}

View File

@ -193,6 +193,74 @@
font-weight: 600; font-weight: 600;
} }
/* ── FullCalendar "+more" popover fixes ── */
.fc .fc-more-popover {
background-color: hsl(0 0% 5%);
border-color: hsl(0 0% 14.9%);
border-radius: 0.5rem;
min-width: 220px;
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.5);
}
.fc .fc-more-popover .fc-popover-header {
background-color: hsl(0 0% 7%);
color: hsl(0 0% 98%);
padding: 8px 12px;
border-radius: 0.5rem 0.5rem 0 0;
}
.fc .fc-more-popover .fc-popover-body {
padding: 8px;
}
/* Fix broken close icon — replace font icon with a CSS X */
.fc .fc-more-popover .fc-popover-close {
font-size: 0;
width: 24px;
height: 24px;
position: relative;
opacity: 0.7;
cursor: pointer;
}
.fc .fc-more-popover .fc-popover-close:hover {
opacity: 1;
}
.fc .fc-more-popover .fc-popover-close::before,
.fc .fc-more-popover .fc-popover-close::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 2px;
background-color: hsl(0 0% 98%);
border-radius: 1px;
}
.fc .fc-more-popover .fc-popover-close::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.fc .fc-more-popover .fc-popover-close::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
@media (max-width: 767px) {
.fc .fc-more-popover {
min-width: 200px;
}
.fc .fc-more-popover .fc-popover-body {
padding: 6px;
}
.fc .fc-more-popover .fc-daygrid-event {
font-size: 0.7rem;
padding: 2px 6px;
margin-bottom: 2px;
}
}
/* ── Chromium native date picker icon fix (safety net) ── */ /* ── Chromium native date picker icon fix (safety net) ── */
input[type="date"]::-webkit-calendar-picker-indicator, input[type="date"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator { input[type="datetime-local"]::-webkit-calendar-picker-indicator {
@ -223,6 +291,86 @@ form[data-submitted] input:invalid + button {
box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25); box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25);
} }
/* ── Global mobile content scaling ── */
/* Scales down all page content text on mobile. Navbar and UMBRA title are excluded
because they live outside .mobile-scale and use their own sizing. */
@media (max-width: 767px) {
.mobile-scale {
font-size: 0.8125rem; /* 13px base instead of 16px */
}
.mobile-scale h1 {
font-size: 1.375rem !important; /* 22px instead of 30px */
}
.mobile-scale h2 {
font-size: 1.125rem !important;
}
.mobile-scale .text-sm {
font-size: 0.6875rem; /* 11px */
}
.mobile-scale .text-xs {
font-size: 0.625rem; /* 10px — floor for readability */
}
.mobile-scale .text-lg {
font-size: 0.9375rem; /* 15px */
}
.mobile-scale .text-xl {
font-size: 1.0625rem; /* 17px */
}
.mobile-scale .text-2xl {
font-size: 1.1875rem; /* 19px */
}
.mobile-scale .text-3xl {
font-size: 1.375rem; /* 22px */
}
}
/* ── Mobile touch optimisation ── */
@media (max-width: 767px) {
button, a, [role="button"], input, select, textarea {
touch-action: manipulation;
}
}
/* ── FullCalendar mobile overrides ── */
@media (max-width: 767px) {
.fc .fc-daygrid-event {
font-size: 0.6rem;
padding: 0 2px;
line-height: 1.25;
}
/* Hide event times in month view on mobile — Google Calendar style */
.fc .fc-daygrid-event .fc-event-time {
display: none;
}
.fc .fc-timegrid-event {
font-size: 0.6rem;
}
.fc .fc-timegrid-event .fc-event-main {
padding: 1px 2px;
}
.fc .fc-daygrid-day-number {
font-size: 0.7rem;
padding: 2px 4px;
}
.fc .fc-col-header-cell-cushion {
font-size: 0.65rem;
padding: 3px 2px;
}
.fc .fc-timegrid-slot-label {
font-size: 0.6rem;
}
.fc .fc-daygrid-more-link {
font-size: 0.625rem;
}
}
/* ── Ambient background animations ── */ /* ── Ambient background animations ── */
@keyframes drift-1 { @keyframes drift-1 {

View File

@ -1,6 +1,7 @@
import type { Config } from 'tailwindcss'; import type { Config } from 'tailwindcss';
export default { export default {
future: { hoverOnlyWhenSupported: true },
darkMode: ['class'], darkMode: ['class'],
content: [ content: [
'./index.html', './index.html',