Compare commits

...

4 Commits

Author SHA1 Message Date
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
11 changed files with 103 additions and 17 deletions

View File

@ -8,7 +8,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import axios from 'axios';
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
@ -17,6 +17,7 @@ import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import CalendarSidebar from './CalendarSidebar';
import EventDetailPanel from './EventDetailPanel';
import type { CreateDefaults } from './EventDetailPanel';
@ -164,6 +165,8 @@ export default function CalendarPage() {
// Track desktop breakpoint to prevent dual EventDetailPanel mount
const isDesktop = useMediaQuery('(min-width: 1024px)');
const isMobile = useMediaQuery('(max-width: 1023px)');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
// Continuously resize calendar during panel open/close CSS transition
useEffect(() => {
@ -186,6 +189,8 @@ export default function CalendarPage() {
if (!el) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handleWheel = (e: WheelEvent) => {
// Skip wheel navigation on touch devices (let them scroll normally)
if ('ontouchstart' in window) return;
const api = calendarRef.current?.getApi();
if (!api || api.view.type !== 'dayGridMonth') return;
e.preventDefault();
@ -471,15 +476,28 @@ export default function CalendarPage() {
return (
<div className="flex h-full overflow-hidden animate-fade-in">
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
<div
onMouseDown={handleSidebarMouseDown}
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
/>
<div className="hidden lg:flex lg:flex-row shrink-0">
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
<div
onMouseDown={handleSidebarMouseDown}
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
/>
</div>
{isMobile && (
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetContent className="w-72 p-0">
<CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} />
</SheetContent>
</Sheet>
)}
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar */}
<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">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
<ChevronLeft className="h-4 w-4" />

View File

@ -131,7 +131,7 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span>
<button
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" />
</button>
@ -184,7 +184,7 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
setEditingTemplate(tmpl);
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" />
</button>
@ -194,7 +194,7 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
if (!window.confirm(`Delete template "${tmpl.name}"?`)) return;
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" />
</button>

View File

@ -73,7 +73,7 @@ export default function SharedCalendarSection({
<button
type="button"
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" />
</button>
@ -104,7 +104,7 @@ export default function SharedCalendarSection({
<span className="text-sm text-foreground truncate flex-1">{m.calendar_name}</span>
<button
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" />
</button>

View File

@ -356,6 +356,17 @@ export default function LocationsPage() {
sortDir={sortDir}
onSort={handleSort}
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-[10px] text-muted-foreground">{location.category}</span>}
</div>
{location.address && (
<p className="text-xs text-muted-foreground truncate">{location.address}</p>
)}
</div>
)}
/>
)}
</div>

View File

@ -316,7 +316,7 @@ export default function NotificationsPage() {
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</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 && (
<button
onClick={(e) => { e.stopPropagation(); handleMarkRead(notification.id); }}

View File

@ -744,6 +744,18 @@ export default function PeoplePage() {
sortDir={sortDir}
onSort={handleSort}
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-[10px] 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>

View File

@ -53,7 +53,7 @@ function KanbanColumn({
return (
<div
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'
}`}
>
@ -200,7 +200,7 @@ export default function KanbanBoard({
collisionDetection={closestCorners}
onDragEnd={handleDragEnd}
>
<div className="flex gap-3 overflow-x-auto pb-2">
<div className="flex gap-3 overflow-x-auto pb-2" style={{ WebkitOverflowScrolling: "touch" }}>
{tasksByStatus.map(({ column, tasks: colTasks }) => (
<KanbanColumn
key={column.id}

View File

@ -484,7 +484,7 @@ export default function TaskDetailPanel({
<Button
variant="ghost"
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) => {
e.stopPropagation();
handleDeleteSubtask(subtask.id, subtask.title);
@ -527,7 +527,7 @@ export default function TaskDetailPanel({
<Button
variant="ghost"
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={() => {
if (!window.confirm('Delete this comment?')) return;
deleteCommentMutation.mutate(comment.id);

View File

@ -27,7 +27,7 @@ export default function CopyableField({ value, icon: Icon, label }: CopyableFiel
type="button"
onClick={handleCopy}
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" />}
</button>

View File

@ -1,6 +1,7 @@
import React from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { VisibilityMode } from '@/hooks/useTableVisibility';
import { useMediaQuery } from '@/hooks/useMediaQuery';
export interface ColumnDef<T> {
key: string;
@ -28,6 +29,7 @@ interface EntityTableProps<T extends { id: number }> {
onSort: (key: string) => void;
visibilityMode: VisibilityMode;
loading?: boolean;
mobileCardRender?: (item: T) => React.ReactNode;
}
const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
@ -127,10 +129,51 @@ export function EntityTable<T extends { id: number }>({
onSort,
visibilityMode,
loading = false,
mobileCardRender,
}: EntityTableProps<T>) {
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
const colCount = visibleColumns.length;
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">
{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">
{mobileCardRender(item)}
</div>
))}
</>
)}
</React.Fragment>
))}
</>
)}
</div>
);
}
return (
<div className="w-full">

View File

@ -2,6 +2,7 @@ import * as React from 'react';
import { createPortal } from 'react-dom';
import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useMediaQuery } from '@/hooks/useMediaQuery';
// ── 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 [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 });
const isMobile = useMediaQuery('(max-width: 767px)');
React.useImperativeHandle(ref, () => triggerRef.current!);