feat: mobile experience overhaul — touch targets, calendar mobile layout, swipe gestures
This commit is contained in:
@@ -78,6 +78,7 @@
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/list": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
||||
Generated
+12
@@ -23,6 +23,9 @@ importers:
|
||||
'@fullcalendar/interaction':
|
||||
specifier: ^6.1.20
|
||||
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||
'@fullcalendar/list':
|
||||
specifier: ^6.1.20
|
||||
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||
'@fullcalendar/react':
|
||||
specifier: ^6.1.20
|
||||
version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -976,6 +979,11 @@ packages:
|
||||
peerDependencies:
|
||||
'@fullcalendar/core': ~6.1.20
|
||||
|
||||
'@fullcalendar/list@6.1.20':
|
||||
resolution: {integrity: sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==}
|
||||
peerDependencies:
|
||||
'@fullcalendar/core': ~6.1.20
|
||||
|
||||
'@fullcalendar/react@6.1.20':
|
||||
resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==}
|
||||
peerDependencies:
|
||||
@@ -5810,6 +5818,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@fullcalendar/core': 6.1.20
|
||||
|
||||
'@fullcalendar/list@6.1.20(@fullcalendar/core@6.1.20)':
|
||||
dependencies:
|
||||
'@fullcalendar/core': 6.1.20
|
||||
|
||||
'@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@fullcalendar/core': 6.1.20
|
||||
|
||||
@@ -1424,3 +1424,90 @@ select {
|
||||
.animate-emoji-float {
|
||||
animation: emojiFloat 1.8s ease-out both;
|
||||
}
|
||||
|
||||
/* ── Calendar min-height CSS variable ───────────────────────────── */
|
||||
/* Used by calendar-shell so the grid fills the viewport correctly */
|
||||
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
|
||||
:root {
|
||||
--cal-topbar-h: 57px;
|
||||
--cal-nav-h: 0px;
|
||||
}
|
||||
:where(html[data-nav="bottom"]),
|
||||
:where(html[data-nav="fab"]) {
|
||||
--cal-nav-h: 62px;
|
||||
}
|
||||
|
||||
/* ── Mobile overrides (≤ 759 px) ────────────────────────────────── */
|
||||
@media (max-width: 759px) {
|
||||
/* Prevent iOS Safari from zooming into inputs (requires ≥ 16 px) */
|
||||
.input,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Topbar: tighten padding; avatar is redundant (Settings in bottom nav) */
|
||||
.topbar {
|
||||
padding: 10px 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
.topbar-actions > .avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Keyboard shortcut hints are meaningless on touch */
|
||||
.kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Eliminate 300 ms tap delay on interactive controls */
|
||||
.btn,
|
||||
.checkbox,
|
||||
.nav-item,
|
||||
.list-row,
|
||||
.fab,
|
||||
.bottom-nav button,
|
||||
.bottom-nav a {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Momentum scrolling + prevent page bounce fighting in-app scroll */
|
||||
.scroll-area {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* Checkbox: keep the 18 px visual but expand the tap area to 44 px */
|
||||
.checkbox {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.checkbox::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -13px; /* 18 + 2×13 = 44 px tap target */
|
||||
}
|
||||
|
||||
/* Delete button on list rows: always visible on touch (no hover state) */
|
||||
.list-row-delete {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape phone: compress bottom nav to icon-only to reclaim vertical space */
|
||||
@media (max-width: 759px) and (orientation: landscape) and (max-height: 500px) {
|
||||
.bottom-nav {
|
||||
height: 44px;
|
||||
padding: 2px 8px env(safe-area-inset-bottom, 2px);
|
||||
}
|
||||
.bottom-nav .nav-label {
|
||||
display: none;
|
||||
}
|
||||
:where(html[data-nav="bottom"]) .scroll-area {
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
.fab {
|
||||
bottom: 50px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ const jetbrains = JetBrains_Mono({
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#1F1B16",
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@@ -4,10 +4,11 @@ import FullCalendar from "@fullcalendar/react";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import listPlugin from "@fullcalendar/list";
|
||||
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
||||
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2, X } from "lucide-react";
|
||||
import { useMemo, useState, useEffect, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -44,6 +45,8 @@ type EventDraft = {
|
||||
remindMinutesBefore: number | null;
|
||||
};
|
||||
|
||||
type MobileView = "day" | "week" | "agenda";
|
||||
|
||||
const DEFAULT_COLOR = "#B85C3C";
|
||||
|
||||
const VIEW_MAP: Record<CalView, string> = {
|
||||
@@ -52,8 +55,13 @@ const VIEW_MAP: Record<CalView, string> = {
|
||||
day: "timeGridDay",
|
||||
};
|
||||
|
||||
const MOBILE_VIEW_MAP: Record<MobileView, string> = {
|
||||
day: "timeGridDay",
|
||||
week: "timeGridWeek",
|
||||
agenda: "listWeek",
|
||||
};
|
||||
|
||||
function withAlpha(hex: string, alpha: number): string {
|
||||
// Accept #RGB / #RRGGBB / non-hex (return as-is for non-hex e.g. var(--))
|
||||
if (!hex.startsWith("#")) return hex;
|
||||
let h = hex.slice(1);
|
||||
if (h.length === 3)
|
||||
@@ -88,6 +96,17 @@ export function CalendarShell({
|
||||
);
|
||||
const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? "");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [showCalendarsPanel, setShowCalendarsPanel] = useState(false);
|
||||
const [mobileView, setMobileView] = useState<MobileView>("day");
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 759px)");
|
||||
const apply = () => setIsMobile(mq.matches);
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
}, []);
|
||||
|
||||
const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId)
|
||||
? lastCalendarId
|
||||
@@ -289,106 +308,49 @@ export function CalendarShell({
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[calc(100vh-57px)] grid-cols-1 lg:grid-cols-[280px_1fr]">
|
||||
<aside className="border-b bg-sidebar p-4 lg:border-r lg:border-b-0">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Calendar</h1>
|
||||
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
|
||||
<Plus />
|
||||
<span className="sr-only">New event</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{calendarRows.map((calendar) => (
|
||||
<div key={calendar.id} className="rounded-lg border bg-background p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-7 place-items-center rounded-md border"
|
||||
onClick={() => toggleCalendar(calendar.id)}
|
||||
aria-label={`${visibleIds.has(calendar.id) ? "Hide" : "Show"} ${calendar.name}`}
|
||||
>
|
||||
{visibleIds.has(calendar.id) ? <Eye /> : <EyeOff />}
|
||||
</button>
|
||||
<span
|
||||
className="size-3 rounded-full"
|
||||
style={{ backgroundColor: calendar.color ?? DEFAULT_COLOR }}
|
||||
/>
|
||||
<Input
|
||||
aria-label={`${calendar.name} name`}
|
||||
value={calendar.name}
|
||||
onChange={(event) => updateCalendarName(calendar, event.target.value)}
|
||||
onBlur={() => commitCalendarName(calendar)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<Input
|
||||
aria-label={`${calendar.name} color`}
|
||||
type="color"
|
||||
value={calendar.color ?? DEFAULT_COLOR}
|
||||
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
|
||||
/>
|
||||
<Select
|
||||
value={calendar.visibility}
|
||||
onValueChange={(value) =>
|
||||
updateCalendar(calendar, {
|
||||
visibility: value as "private" | "household",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label={`${calendar.name} visibility`}>
|
||||
<SelectValue>
|
||||
{calendar.visibility === "household" ? "Household" : "Private"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="household">Household</SelectItem>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => removeCalendar(calendar.id)}
|
||||
aria-label={`Delete ${calendar.name}`}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border bg-background p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarPlus className="size-4" />
|
||||
New calendar
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="calendar-name">Name</Label>
|
||||
<Input
|
||||
id="calendar-name"
|
||||
value={calendarName}
|
||||
onChange={(event) => setCalendarName(event.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-2">
|
||||
// Shared calendar list + new-calendar form rendered in both the desktop aside
|
||||
// and the mobile bottom-sheet panel.
|
||||
const calendarsPanelContent = (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{calendarRows.map((calendar) => (
|
||||
<div key={calendar.id} className="rounded-lg border bg-background p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-7 place-items-center rounded-md border"
|
||||
onClick={() => toggleCalendar(calendar.id)}
|
||||
aria-label={`${visibleIds.has(calendar.id) ? "Hide" : "Show"} ${calendar.name}`}
|
||||
>
|
||||
{visibleIds.has(calendar.id) ? <Eye /> : <EyeOff />}
|
||||
</button>
|
||||
<span
|
||||
className="size-3 rounded-full"
|
||||
style={{ backgroundColor: calendar.color ?? DEFAULT_COLOR }}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Calendar color"
|
||||
aria-label={`${calendar.name} name`}
|
||||
value={calendar.name}
|
||||
onChange={(event) => updateCalendarName(calendar, event.target.value)}
|
||||
onBlur={() => commitCalendarName(calendar)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<Input
|
||||
aria-label={`${calendar.name} color`}
|
||||
type="color"
|
||||
value={calendarColor}
|
||||
onChange={(event) => setCalendarColorValue(event.target.value)}
|
||||
value={calendar.color ?? DEFAULT_COLOR}
|
||||
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
|
||||
/>
|
||||
<Select
|
||||
value={calendarVisibility}
|
||||
value={calendar.visibility}
|
||||
onValueChange={(value) =>
|
||||
setCalendarVisibilityValue(value as "private" | "household")
|
||||
updateCalendar(calendar, { visibility: value as "private" | "household" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="Calendar visibility">
|
||||
<SelectTrigger aria-label={`${calendar.name} visibility`}>
|
||||
<SelectValue>
|
||||
{calendarVisibility === "household" ? "Household" : "Private"}
|
||||
{calendar.visibility === "household" ? "Household" : "Private"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -396,179 +358,336 @@ export function CalendarShell({
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => removeCalendar(calendar.id)}
|
||||
aria-label={`Delete ${calendar.name}`}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
|
||||
<Check />
|
||||
Create calendar
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border bg-background p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarPlus className="size-4" />
|
||||
New calendar
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="calendar-name">Name</Label>
|
||||
<Input
|
||||
id="calendar-name"
|
||||
value={calendarName}
|
||||
onChange={(event) => setCalendarName(event.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-2">
|
||||
<Input
|
||||
aria-label="Calendar color"
|
||||
type="color"
|
||||
value={calendarColor}
|
||||
onChange={(event) => setCalendarColorValue(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={calendarVisibility}
|
||||
onValueChange={(value) =>
|
||||
setCalendarVisibilityValue(value as "private" | "household")
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="Calendar visibility">
|
||||
<SelectValue>
|
||||
{calendarVisibility === "household" ? "Household" : "Private"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="household">Household</SelectItem>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
|
||||
<Check />
|
||||
Create calendar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const fcEvents = visibleEvents.map((event) => {
|
||||
const color =
|
||||
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ?? DEFAULT_COLOR;
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: event.startAt,
|
||||
end: event.endAt,
|
||||
allDay: event.allDay,
|
||||
backgroundColor: withAlpha(color, 0.14),
|
||||
borderColor: color,
|
||||
textColor: "var(--ink-2)",
|
||||
extendedProps: { calendarId: event.calendarId, color },
|
||||
};
|
||||
});
|
||||
|
||||
// Shared event form content — rendered inside both the desktop centered modal
|
||||
// and the mobile bottom-sheet.
|
||||
const eventFormContent = selectedEvent && (
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="event-title">Title</Label>
|
||||
<Input
|
||||
id="event-title"
|
||||
value={selectedEvent.title}
|
||||
onChange={(event) => setSelectedEvent({ ...selectedEvent, title: event.target.value })}
|
||||
/>
|
||||
<Label htmlFor="event-calendar">Calendar</Label>
|
||||
<Select
|
||||
value={selectedEvent.calendarId}
|
||||
onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })}
|
||||
>
|
||||
<SelectTrigger id="event-calendar">
|
||||
<SelectValue>
|
||||
{calendarRows.find((c) => c.id === selectedEvent.calendarId)?.name ??
|
||||
"Select a calendar"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{calendarRows.map((calendar) => (
|
||||
<SelectItem key={calendar.id} value={calendar.id}>
|
||||
{calendar.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-start">Start</Label>
|
||||
<Input
|
||||
id="event-start"
|
||||
type="datetime-local"
|
||||
value={selectedEvent.startAt}
|
||||
onChange={(event) =>
|
||||
setSelectedEvent({ ...selectedEvent, startAt: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-end">End</Label>
|
||||
<Input
|
||||
id="event-end"
|
||||
type="datetime-local"
|
||||
value={selectedEvent.endAt}
|
||||
onChange={(event) => setSelectedEvent({ ...selectedEvent, endAt: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label htmlFor="event-location">Location</Label>
|
||||
<Input
|
||||
id="event-location"
|
||||
value={selectedEvent.location}
|
||||
onChange={(event) => setSelectedEvent({ ...selectedEvent, location: event.target.value })}
|
||||
/>
|
||||
<Label htmlFor="event-notes">Notes</Label>
|
||||
<textarea
|
||||
id="event-notes"
|
||||
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={selectedEvent.notes}
|
||||
onChange={(event) => setSelectedEvent({ ...selectedEvent, notes: event.target.value })}
|
||||
/>
|
||||
{!selectedEvent.id && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 cursor-pointer"
|
||||
checked={selectedEvent.remindMinutesBefore !== null}
|
||||
onChange={(e) =>
|
||||
setSelectedEvent({
|
||||
...selectedEvent,
|
||||
remindMinutesBefore: e.target.checked ? 30 : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Remind me 30 min before
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 pt-2">
|
||||
<div className="flex gap-2">
|
||||
{selectedEvent.id && (
|
||||
<Button variant="destructive" onClick={removeSelectedEvent}>
|
||||
Delete event
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedEvent.id && (
|
||||
<ShareButton entityType="calendar.event" entityId={selectedEvent.id} />
|
||||
)}
|
||||
<Button onClick={saveSelectedEvent} disabled={!selectedEvent.title || isPending}>
|
||||
Save event
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-[280px_1fr]"
|
||||
style={{ minHeight: "calc(100dvh - var(--cal-topbar-h) - var(--cal-nav-h))" }}
|
||||
>
|
||||
{/* Desktop aside — hidden on mobile */}
|
||||
<aside
|
||||
className={`${isMobile ? "hidden" : ""} border-b bg-sidebar p-4 lg:border-r lg:border-b-0`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Calendar</h1>
|
||||
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
|
||||
<Plus />
|
||||
<span className="sr-only">New event</span>
|
||||
</Button>
|
||||
</div>
|
||||
{calendarsPanelContent}
|
||||
</aside>
|
||||
|
||||
<section className="fc-skin min-w-0 p-4">
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView={VIEW_MAP[defaultView]}
|
||||
headerToolbar={{
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
}}
|
||||
buttonText={{
|
||||
today: "Today",
|
||||
month: "Month",
|
||||
week: "Week",
|
||||
day: "Day",
|
||||
}}
|
||||
dayHeaderFormat={{ weekday: "short" }}
|
||||
selectable
|
||||
editable
|
||||
eventResizableFromStart
|
||||
select={handleDateSelect}
|
||||
eventClick={openExistingEvent}
|
||||
eventDrop={moveEvent}
|
||||
eventResize={moveEvent}
|
||||
events={visibleEvents.map((event) => {
|
||||
const color =
|
||||
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
|
||||
DEFAULT_COLOR;
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: event.startAt,
|
||||
end: event.endAt,
|
||||
allDay: event.allDay,
|
||||
backgroundColor: withAlpha(color, 0.14),
|
||||
borderColor: color,
|
||||
textColor: "var(--ink-2)",
|
||||
extendedProps: { calendarId: event.calendarId, color },
|
||||
};
|
||||
})}
|
||||
eventClassNames={(arg) => {
|
||||
const id = String(arg.event.extendedProps["calendarId"] ?? "");
|
||||
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
|
||||
}}
|
||||
height="auto"
|
||||
/>
|
||||
{/* Calendar section */}
|
||||
<section className="fc-skin flex min-w-0 flex-col">
|
||||
{/* Mobile toolbar: view switcher + calendars button + new event */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className="flex items-center gap-2 border-b px-3 py-2"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<div className="seg flex-1">
|
||||
{(["day", "week", "agenda"] as MobileView[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
aria-selected={mobileView === v}
|
||||
onClick={() => setMobileView(v)}
|
||||
>
|
||||
{v.charAt(0).toUpperCase() + v.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowCalendarsPanel(true)}
|
||||
>
|
||||
<Eye size={14} />
|
||||
Cals
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => openNewEvent()}>
|
||||
<Plus size={14} />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 p-4">
|
||||
<FullCalendar
|
||||
key={isMobile ? `mobile-${mobileView}` : `desktop-${defaultView}`}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
|
||||
initialView={isMobile ? MOBILE_VIEW_MAP[mobileView] : VIEW_MAP[defaultView]}
|
||||
headerToolbar={
|
||||
isMobile
|
||||
? { left: "prev,next", center: "title", right: "today" }
|
||||
: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
}
|
||||
}
|
||||
buttonText={{ today: "Today", month: "Month", week: "Week", day: "Day", list: "List" }}
|
||||
dayHeaderFormat={{ weekday: "short" }}
|
||||
selectable={!isMobile}
|
||||
editable={!isMobile}
|
||||
eventResizableFromStart={!isMobile}
|
||||
select={handleDateSelect}
|
||||
eventClick={openExistingEvent}
|
||||
eventDrop={moveEvent}
|
||||
eventResize={moveEvent}
|
||||
events={fcEvents}
|
||||
eventClassNames={(arg) => {
|
||||
const id = String(arg.event.extendedProps["calendarId"] ?? "");
|
||||
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
|
||||
}}
|
||||
height="auto"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mobile: calendars bottom sheet */}
|
||||
{isMobile && showCalendarsPanel && (
|
||||
<div
|
||||
className="fixed inset-0 z-[45] bg-black/40"
|
||||
onClick={() => setShowCalendarsPanel(false)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-0 overflow-auto rounded-t-[var(--r-xl)]"
|
||||
style={{
|
||||
bottom: "var(--cal-nav-h)",
|
||||
maxHeight: "72dvh",
|
||||
background: "var(--card)",
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between border-b p-4"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<h3 className="serif text-[16px] font-medium">Calendars</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-icon"
|
||||
aria-label="Close calendars panel"
|
||||
onClick={() => setShowCalendarsPanel(false)}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">{calendarsPanelContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event form — centered modal on desktop, bottom sheet on mobile */}
|
||||
{selectedEvent && (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/20 p-4">
|
||||
<div className="w-full max-w-lg rounded-lg bg-popover p-4 text-popover-foreground shadow-lg">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex bg-black/20"
|
||||
style={
|
||||
isMobile
|
||||
? { alignItems: "flex-end" }
|
||||
: { alignItems: "center", justifyContent: "center" }
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setSelectedEvent(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="overflow-auto bg-popover text-popover-foreground shadow-lg"
|
||||
style={{
|
||||
borderRadius: isMobile ? "var(--r-xl) var(--r-xl) 0 0" : "var(--r-lg)",
|
||||
width: isMobile ? "100%" : undefined,
|
||||
maxWidth: isMobile ? undefined : "512px",
|
||||
maxHeight: "90dvh",
|
||||
padding: "1rem",
|
||||
paddingBottom: isMobile ? "calc(1rem + env(safe-area-inset-bottom))" : "1rem",
|
||||
}}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{selectedEvent.id ? "Edit event" : "New event"}
|
||||
</h2>
|
||||
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
|
||||
<span aria-hidden>×</span>
|
||||
<X size={16} />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="event-title">Title</Label>
|
||||
<Input
|
||||
id="event-title"
|
||||
value={selectedEvent.title}
|
||||
onChange={(event) =>
|
||||
setSelectedEvent({ ...selectedEvent, title: event.target.value })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="event-calendar">Calendar</Label>
|
||||
<Select
|
||||
value={selectedEvent.calendarId}
|
||||
onValueChange={(value) =>
|
||||
setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="event-calendar">
|
||||
<SelectValue>
|
||||
{calendarRows.find((c) => c.id === selectedEvent.calendarId)?.name ??
|
||||
"Select a calendar"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{calendarRows.map((calendar) => (
|
||||
<SelectItem key={calendar.id} value={calendar.id}>
|
||||
{calendar.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-start">Start</Label>
|
||||
<Input
|
||||
id="event-start"
|
||||
type="datetime-local"
|
||||
value={selectedEvent.startAt}
|
||||
onChange={(event) =>
|
||||
setSelectedEvent({ ...selectedEvent, startAt: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-end">End</Label>
|
||||
<Input
|
||||
id="event-end"
|
||||
type="datetime-local"
|
||||
value={selectedEvent.endAt}
|
||||
onChange={(event) =>
|
||||
setSelectedEvent({ ...selectedEvent, endAt: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label htmlFor="event-location">Location</Label>
|
||||
<Input
|
||||
id="event-location"
|
||||
value={selectedEvent.location}
|
||||
onChange={(event) =>
|
||||
setSelectedEvent({ ...selectedEvent, location: event.target.value })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="event-notes">Notes</Label>
|
||||
<textarea
|
||||
id="event-notes"
|
||||
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={selectedEvent.notes}
|
||||
onChange={(event) =>
|
||||
setSelectedEvent({ ...selectedEvent, notes: event.target.value })
|
||||
}
|
||||
/>
|
||||
{!selectedEvent.id && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 cursor-pointer"
|
||||
checked={selectedEvent.remindMinutesBefore !== null}
|
||||
onChange={(e) =>
|
||||
setSelectedEvent({
|
||||
...selectedEvent,
|
||||
remindMinutesBefore: e.target.checked ? 30 : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Remind me 30 min before
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 pt-2">
|
||||
<div className="flex gap-2">
|
||||
{selectedEvent.id && (
|
||||
<Button variant="destructive" onClick={removeSelectedEvent}>
|
||||
Delete event
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedEvent.id && (
|
||||
<ShareButton entityType="calendar.event" entityId={selectedEvent.id} />
|
||||
)}
|
||||
<Button onClick={saveSelectedEvent} disabled={!selectedEvent.title || isPending}>
|
||||
Save event
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{eventFormContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -194,57 +194,142 @@ function ListItemRow({
|
||||
onCommit: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const swipeStart = useRef<number | null>(null);
|
||||
const swipeOrigin = useRef<{ x: number; y: number } | null>(null);
|
||||
const isScrolling = useRef(false);
|
||||
const [swipeX, setSwipeX] = useState(0);
|
||||
|
||||
const THRESHOLD = 80; // px to commit the swipe action
|
||||
const MAX_SWIPE = 100; // px maximum visual displacement
|
||||
|
||||
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
swipeOrigin.current = { x: e.clientX, y: e.clientY };
|
||||
isScrolling.current = false;
|
||||
setSwipeX(0);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!swipeOrigin.current) return;
|
||||
const dx = e.clientX - swipeOrigin.current.x;
|
||||
const dy = Math.abs(e.clientY - swipeOrigin.current.y);
|
||||
// If the gesture is primarily vertical, treat as scroll and abort swipe.
|
||||
if (!isScrolling.current && dy > Math.abs(dx) && dy > 8) {
|
||||
isScrolling.current = true;
|
||||
setSwipeX(0);
|
||||
return;
|
||||
}
|
||||
if (isScrolling.current) return;
|
||||
setSwipeX(Math.max(-MAX_SWIPE, Math.min(MAX_SWIPE, dx)));
|
||||
}
|
||||
|
||||
function handlePointerUp(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (swipeOrigin.current && !isScrolling.current) {
|
||||
const dx = e.clientX - swipeOrigin.current.x;
|
||||
if (dx > THRESHOLD) onToggle(!item.done); // right → complete / uncomplete
|
||||
if (dx < -THRESHOLD) onRemove(); // left → delete
|
||||
}
|
||||
swipeOrigin.current = null;
|
||||
setSwipeX(0);
|
||||
}
|
||||
|
||||
function handlePointerCancel() {
|
||||
swipeOrigin.current = null;
|
||||
setSwipeX(0);
|
||||
}
|
||||
|
||||
// Background tint that deepens as the swipe approaches the threshold.
|
||||
const swipeProgress = Math.abs(swipeX) / MAX_SWIPE;
|
||||
const swipeBg =
|
||||
swipeX > 16
|
||||
? `color-mix(in oklab, var(--ok) ${swipeProgress * 55}%, var(--card))`
|
||||
: swipeX < -16
|
||||
? `color-mix(in oklab, var(--bad) ${swipeProgress * 55}%, var(--card))`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`list-row ${item.done ? "checked" : ""} group/row`}
|
||||
onPointerDown={(event) => {
|
||||
swipeStart.current = event.clientX;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const start = swipeStart.current;
|
||||
if (start !== null && event.clientX - start < -60) onRemove();
|
||||
swipeStart.current = null;
|
||||
}}
|
||||
className="relative overflow-hidden border-b-[0.5px] last:border-b-0 group/row"
|
||||
style={{ borderColor: "var(--hair)", background: swipeBg, touchAction: "pan-y" }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Complete ${item.text}`}
|
||||
aria-pressed={item.done}
|
||||
className={`checkbox ${item.done ? "on" : ""}`}
|
||||
onClick={() => onToggle(!item.done)}
|
||||
{/* Swipe affordance icons revealed behind the sliding content */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-3 flex items-center"
|
||||
aria-hidden
|
||||
style={{ opacity: Math.max(0, swipeX - 16) / (MAX_SWIPE - 16), color: "var(--ok)" }}
|
||||
>
|
||||
{item.done && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
aria-label={`${item.text} text`}
|
||||
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px]"
|
||||
value={item.text}
|
||||
onChange={(event) => onEdit(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Delete ${item.text}`}
|
||||
onClick={onRemove}
|
||||
className="opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-y-0 right-3 flex items-center"
|
||||
aria-hidden
|
||||
style={{ opacity: Math.max(0, -swipeX - 16) / (MAX_SWIPE - 16), color: "var(--bad)" }}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Trash2 size={16} />
|
||||
</div>
|
||||
|
||||
{/* Sliding content row */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-[14px] py-[10px] hover:bg-[var(--shade)] ${item.done ? "checked" : ""}`}
|
||||
style={{
|
||||
transform: `translateX(${swipeX}px)`,
|
||||
transition: swipeX === 0 ? "transform 0.22s ease-out" : "none",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Complete ${item.text}`}
|
||||
aria-pressed={item.done}
|
||||
className={`checkbox ${item.done ? "on" : ""}`}
|
||||
onClick={() => onToggle(!item.done)}
|
||||
>
|
||||
{item.done && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
aria-label={`${item.text} text`}
|
||||
className={`!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px] ${item.done ? "line-through text-[var(--ink-faint)]" : ""}`}
|
||||
value={item.text}
|
||||
onChange={(event) => onEdit(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
{/* Always visible on touch (list-row-delete makes opacity-0 override to 1 on mobile) */}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Delete ${item.text}`}
|
||||
onClick={onRemove}
|
||||
className="list-row-delete opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user