Merge feature/event-panel-ux: scroll bleed fix, auto-grow description, compact layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
1daec977ba
@ -187,12 +187,13 @@ export default function CalendarPage() {
|
|||||||
return () => cancelAnimationFrame(rafId);
|
return () => cancelAnimationFrame(rafId);
|
||||||
}, [panelOpen]);
|
}, [panelOpen]);
|
||||||
|
|
||||||
// Scroll wheel navigation in month view
|
// Scroll wheel navigation in month view (disabled when detail panel is open)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = calendarContainerRef.current;
|
const el = calendarContainerRef.current;
|
||||||
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) => {
|
||||||
|
if (panelOpen) return;
|
||||||
// Skip wheel navigation on touch devices (let them scroll normally)
|
// Skip wheel navigation on touch devices (let them scroll normally)
|
||||||
if ('ontouchstart' in window) return;
|
if ('ontouchstart' in window) return;
|
||||||
const api = calendarRef.current?.getApi();
|
const api = calendarRef.current?.getApi();
|
||||||
@ -207,7 +208,7 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
el.addEventListener('wheel', handleWheel, { passive: false });
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
return () => el.removeEventListener('wheel', handleWheel);
|
return () => el.removeEventListener('wheel', handleWheel);
|
||||||
}, []);
|
}, [panelOpen]);
|
||||||
|
|
||||||
// AW-2: Track visible date range for scoped event fetching
|
// AW-2: Track visible date range for scoped event fetching
|
||||||
// W-02 fix: Initialize from current month to avoid unscoped first fetch
|
// W-02 fix: Initialize from current month to avoid unscoped first fetch
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
@ -284,6 +284,17 @@ export default function EventDetailPanel({
|
|||||||
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
|
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
|
||||||
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
||||||
const [locationSearch, setLocationSearch] = useState('');
|
const [locationSearch, setLocationSearch] = useState('');
|
||||||
|
const descRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Auto-resize description textarea to fit content
|
||||||
|
useEffect(() => {
|
||||||
|
const el = descRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
});
|
||||||
|
}, [editState.description, isEditing]);
|
||||||
|
|
||||||
// Poll lock status in view mode for shared events (Stream A: real-time lock awareness)
|
// Poll lock status in view mode for shared events (Stream A: real-time lock awareness)
|
||||||
// lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly.
|
// lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly.
|
||||||
@ -367,11 +378,11 @@ export default function EventDetailPanel({
|
|||||||
end_datetime: endDt,
|
end_datetime: endDt,
|
||||||
all_day: data.all_day,
|
all_day: data.all_day,
|
||||||
location_id: data.location_id ? parseInt(data.location_id) : null,
|
location_id: data.location_id ? parseInt(data.location_id) : null,
|
||||||
is_starred: data.is_starred,
|
|
||||||
recurrence_rule: rule,
|
|
||||||
};
|
};
|
||||||
// Invited editors cannot change calendars — omit calendar_id from payload
|
// Invited editors are restricted to the backend allowlist — omit fields they cannot modify
|
||||||
if (!canModifyAsInvitee) {
|
if (!canModifyAsInvitee) {
|
||||||
|
payload.is_starred = data.is_starred;
|
||||||
|
payload.recurrence_rule = rule;
|
||||||
payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null;
|
payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -539,7 +550,8 @@ export default function EventDetailPanel({
|
|||||||
: event?.title || '';
|
: event?.title || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
// onWheel stopPropagation: defence-in-depth with CalendarPage's panelOpen guard to prevent month-scroll bleed
|
||||||
|
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden" onWheel={(e) => e.stopPropagation()}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@ -721,7 +733,7 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : (isEditing || isCreating) ? (
|
) : (isEditing || isCreating) ? (
|
||||||
/* Edit / Create mode */
|
/* Edit / Create mode */
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col gap-3 h-full">
|
||||||
{/* Title (only shown in body for create mode; edit mode has it in header) */}
|
{/* Title (only shown in body for create mode; edit mode has it in header) */}
|
||||||
{isCreating && (
|
{isCreating && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@ -737,18 +749,8 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
{/* All day + Date row */}
|
||||||
<Label htmlFor="panel-desc">Description</Label>
|
<div className="space-y-2">
|
||||||
<Textarea
|
|
||||||
id="panel-desc"
|
|
||||||
value={editState.description}
|
|
||||||
onChange={(e) => updateField('description', e.target.value)}
|
|
||||||
placeholder="Add a description..."
|
|
||||||
rows={3}
|
|
||||||
className="text-sm resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="panel-allday"
|
id="panel-allday"
|
||||||
@ -760,9 +762,8 @@ export default function EventDetailPanel({
|
|||||||
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
|
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="panel-allday">All day event</Label>
|
<Label htmlFor="panel-allday" className="text-xs">All day</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="panel-start" required>Start</Label>
|
<Label htmlFor="panel-start" required>Start</Label>
|
||||||
@ -788,7 +789,9 @@ export default function EventDetailPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar + Location row */}
|
||||||
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
||||||
{!canModifyAsInvitee && (
|
{!canModifyAsInvitee && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@ -837,8 +840,9 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
|
{/* Recurrence + Star row */}
|
||||||
{!canModifyAsInvitee && (
|
{!canModifyAsInvitee && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 items-end">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||||
<Select
|
<Select
|
||||||
@ -854,6 +858,15 @@ export default function EventDetailPanel({
|
|||||||
<option value="monthly_date">Monthly (date)</option>
|
<option value="monthly_date">Monthly (date)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pb-2">
|
||||||
|
<Checkbox
|
||||||
|
id="panel-starred"
|
||||||
|
checked={editState.is_starred}
|
||||||
|
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="panel-starred" className="text-xs">Starred</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editState.recurrence_type === 'every_n_days' && (
|
{editState.recurrence_type === 'every_n_days' && (
|
||||||
@ -924,23 +937,17 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Description — fills remaining space */}
|
||||||
<Checkbox
|
<div className="flex flex-col flex-1 min-h-0 space-y-1">
|
||||||
id="panel-starred"
|
<Label htmlFor="panel-desc">Description</Label>
|
||||||
checked={editState.is_starred}
|
<Textarea
|
||||||
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
|
ref={descRef}
|
||||||
|
id="panel-desc"
|
||||||
|
value={editState.description}
|
||||||
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
className="text-sm flex-1 min-h-[80px]"
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="panel-starred">Star this event</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save / Cancel buttons at bottom of form */}
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
||||||
<Button variant="outline" size="sm" onClick={handleEditCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
|
|
||||||
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -1056,15 +1063,17 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description — full width */}
|
{/* Description — full width */}
|
||||||
{event?.description && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
<AlignLeft className="h-3 w-3" />
|
<AlignLeft className="h-3 w-3" />
|
||||||
Description
|
Description
|
||||||
</div>
|
</div>
|
||||||
|
{event?.description ? (
|
||||||
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
|
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
|
||||||
</div>
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">—</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Invitee section — view mode */}
|
{/* Invitee section — view mode */}
|
||||||
{event && !event.is_virtual && (
|
{event && !event.is_virtual && (
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, useEffect, useRef, FormEvent } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
@ -141,6 +141,17 @@ export default function EventForm({ event, templateData, templateName, initialSt
|
|||||||
const existingLocation = locations.find((l) => l.id === source?.location_id);
|
const existingLocation = locations.find((l) => l.id === source?.location_id);
|
||||||
const [locationSearch, setLocationSearch] = useState(existingLocation?.name || '');
|
const [locationSearch, setLocationSearch] = useState(existingLocation?.name || '');
|
||||||
|
|
||||||
|
// Auto-resize description textarea
|
||||||
|
const descRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = descRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
});
|
||||||
|
}, [formData.description]);
|
||||||
|
|
||||||
const selectableCalendars = calendars.filter((c) => !c.is_system);
|
const selectableCalendars = calendars.filter((c) => !c.is_system);
|
||||||
|
|
||||||
const buildRecurrenceRule = (): RecurrenceRule | null => {
|
const buildRecurrenceRule = (): RecurrenceRule | null => {
|
||||||
@ -255,10 +266,12 @@ export default function EventForm({ event, templateData, templateName, initialSt
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
ref={descRef}
|
||||||
id="description"
|
id="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
className="min-h-[80px] flex-1"
|
placeholder="Add a description..."
|
||||||
|
className="min-h-[80px] text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user