feat: redesign recurring edit dialogs with Google Calendar style and i18n

- Redesigned drag/resize and modal recurring edit dialogs with radio buttons
  (This event / This and following / All events) with descriptions
- Added German/English i18n to both recurring edit dialogs
- Repeat-on day buttons now respect weekStartDay setting (Mon/Sun start)
- Added language prop to CalendarEventModal

v1.70.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-24 16:40:33 +01:00
parent 74a8c9a9a9
commit 948d239867
3 changed files with 162 additions and 116 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.69.0", "version": "1.70.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -40,6 +40,8 @@ interface CalendarEventModalProps {
initialDate?: Date; initialDate?: Date;
initialStartTime?: string; initialStartTime?: string;
connections: any[]; connections: any[];
weekStartDay?: number; // 0=Sunday, 1=Monday
language?: string;
onClose: () => void; onClose: () => void;
onSave: (eventData: any) => Promise<void>; onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>; onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
@ -50,6 +52,8 @@ export default function CalendarEventModal({
initialDate, initialDate,
initialStartTime, initialStartTime,
connections, connections,
weekStartDay = 0,
language = 'en',
onClose, onClose,
onSave, onSave,
onDelete onDelete
@ -223,6 +227,7 @@ export default function CalendarEventModal({
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false); const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
const [showRecurringDeleteOptions, setShowRecurringDeleteOptions] = useState(false); const [showRecurringDeleteOptions, setShowRecurringDeleteOptions] = useState(false);
const [showRecurringEditOptions, setShowRecurringEditOptions] = useState(false); const [showRecurringEditOptions, setShowRecurringEditOptions] = useState(false);
const [recurringEditMode, setRecurringEditMode] = useState<'this' | 'future' | 'all'>('this');
const handleDelete = async (mode?: string) => { const handleDelete = async (mode?: string) => {
if (!event?.id || !onDelete) return; if (!event?.id || !onDelete) return;
@ -503,26 +508,30 @@ export default function CalendarEventModal({
<div style={{ ...rowStyle, flexDirection: 'column', alignItems: 'flex-start', gap: '6px' }}> <div style={{ ...rowStyle, flexDirection: 'column', alignItems: 'flex-start', gap: '6px' }}>
<span style={labelStyle}>Repeat on</span> <span style={labelStyle}>Repeat on</span>
<div style={{ display: 'flex', gap: '4px', paddingLeft: '0' }}> <div style={{ display: 'flex', gap: '4px', paddingLeft: '0' }}>
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => ( {(() => {
const allDays = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const dayIndices = Array.from({ length: 7 }, (_, i) => (i + weekStartDay) % 7);
return dayIndices.map(dow => (
<button <button
key={i} key={dow}
onClick={() => { onClick={() => {
setCustomDays(prev => setCustomDays(prev =>
prev.includes(i) ? (prev.length > 1 ? prev.filter(d => d !== i) : prev) : [...prev, i] prev.includes(dow) ? (prev.length > 1 ? prev.filter(d => d !== dow) : prev) : [...prev, dow]
); );
}} }}
style={{ style={{
width: '30px', height: '30px', borderRadius: '50%', width: '30px', height: '30px', borderRadius: '50%',
border: customDays.includes(i) ? '2px solid #3b82f6' : '1px solid var(--weekly-border, #ddd)', border: customDays.includes(dow) ? '2px solid #3b82f6' : '1px solid var(--weekly-border, #ddd)',
backgroundColor: customDays.includes(i) ? '#3b82f6' : 'transparent', backgroundColor: customDays.includes(dow) ? '#3b82f6' : 'transparent',
color: customDays.includes(i) ? 'white' : 'var(--weekly-text)', color: customDays.includes(dow) ? 'white' : 'var(--weekly-text)',
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}} }}
> >
{day} {allDays[dow]}
</button> </button>
))} ));
})()}
</div> </div>
</div> </div>
)} )}
@ -848,7 +857,6 @@ export default function CalendarEventModal({
Cancel Cancel
</button> </button>
)} )}
{!showRecurringEditOptions ? (
<button <button
className="weekly-btn-primary" className="weekly-btn-primary"
onClick={() => handleSubmit()} onClick={() => handleSubmit()}
@ -862,41 +870,74 @@ export default function CalendarEventModal({
> >
{isSaving ? 'Saving...' : 'Save'} {isSaving ? 'Saving...' : 'Save'}
</button> </button>
) : ( </div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}> </div>
<span style={{ fontSize: '0.75rem', color: 'var(--weekly-text-light)', marginRight: '2px' }}>Save:</span> </div>
{[ {/* Recurring edit mode overlay */}
{ mode: 'this', label: 'This event' }, {showRecurringEditOptions && (
{ mode: 'all', label: 'All events' }, <div style={{
].map(({ mode, label }) => ( position: 'fixed', inset: 0, zIndex: 3000,
<button display: 'flex', justifyContent: 'center', alignItems: 'center',
key={mode} background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(2px)',
onClick={() => handleSubmit(mode)} }} onClick={() => setShowRecurringEditOptions(false)}>
disabled={isSaving} <div style={{
style={{ background: 'var(--weekly-bg, white)', color: 'var(--weekly-text, #333)',
padding: '4px 10px', fontSize: '0.78rem', fontWeight: 600, borderRadius: 12, width: '90%', maxWidth: 420,
color: 'white', background: mode === 'this' ? '#3b82f6' : '#6366f1', boxShadow: '0 12px 40px rgba(0,0,0,0.25)',
border: 'none', borderRadius: '5px', overflow: 'hidden',
cursor: 'pointer', opacity: isSaving ? 0.5 : 1, }} onClick={e => e.stopPropagation()}>
}} <div style={{
> padding: '16px 20px 12px', fontWeight: 700, fontSize: '0.95rem',
{isSaving ? '...' : label} borderBottom: '2px solid #3b82f6',
</button> }}>
{language === 'de' ? 'Wiederkehrendes Ereignis bearbeiten' : 'Edit recurring event'}
<button onClick={() => setShowRecurringEditOptions(false)} style={{
float: 'right', background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--weekly-text-light)', padding: 0,
}}><X size={18} /></button>
</div>
<div style={{ padding: '12px 20px' }}>
{([
{ value: 'this' as const, title: language === 'de' ? 'Nur dieses Ereignis' : 'This event', desc: language === 'de' ? 'Alle anderen Ereignisse der Serie bleiben unverändert.' : 'All other events in the series stay the same.' },
{ value: 'future' as const, title: language === 'de' ? 'Dieses und folgende Ereignisse' : 'This and following events', desc: language === 'de' ? 'Dieses und alle zukünftigen Ereignisse der Serie werden geändert.' : 'This and all future events in the series will be changed.' },
{ value: 'all' as const, title: language === 'de' ? 'Alle Ereignisse' : 'All events', desc: language === 'de' ? 'Alle Ereignisse der Serie werden geändert.' : 'All events in the series will be changed.' },
]).map(opt => (
<label key={opt.value} style={{
display: 'flex', gap: 12, padding: '10px 4px', cursor: 'pointer',
borderBottom: '1px solid var(--weekly-border, #eee)',
alignItems: 'flex-start',
}} onClick={() => setRecurringEditMode(opt.value)}>
<input
type="radio" name="recurringEditMode"
checked={recurringEditMode === opt.value}
onChange={() => setRecurringEditMode(opt.value)}
style={{ marginTop: 3, accentColor: '#3b82f6' }}
/>
<div>
<div style={{ fontWeight: 600, fontSize: '0.88rem' }}>{opt.title}</div>
<div style={{ fontSize: '0.78rem', opacity: 0.6, marginTop: 2 }}>{opt.desc}</div>
</div>
</label>
))} ))}
<button </div>
onClick={() => setShowRecurringEditOptions(false)} <div style={{
style={{ padding: '12px 20px', display: 'flex', justifyContent: 'flex-end', gap: 8,
padding: '4px 6px', fontSize: '0.75rem', color: 'var(--weekly-text-light)', }}>
<button onClick={() => setShowRecurringEditOptions(false)} style={{
padding: '8px 16px', fontSize: '0.85rem', fontWeight: 600,
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
}} color: 'var(--weekly-text-light)',
> }}>{language === 'de' ? 'Abbrechen' : 'Cancel'}</button>
<X size={14} /> <button onClick={() => handleSubmit(recurringEditMode)} disabled={isSaving} style={{
</button> padding: '8px 20px', fontSize: '0.85rem', fontWeight: 600,
background: '#3b82f6', color: 'white', border: 'none',
borderRadius: 8, cursor: 'pointer',
opacity: isSaving ? 0.6 : 1,
}}>{isSaving ? (language === 'de' ? 'Speichern...' : 'Saving...') : (language === 'de' ? 'Speichern' : 'Save')}</button>
</div>
</div>
</div> </div>
)} )}
</div> </div>
</div>
</div>
</div>
); );
} }

View File

@ -2247,6 +2247,7 @@ export default function WeeklyView() {
originalStartTime: string; originalStartTime: string;
originalEndTime: string; originalEndTime: string;
} | null>(null); } | null>(null);
const [recurringDragEditMode, setRecurringDragEditMode] = useState<'this' | 'future' | 'all'>('this');
// Dark Mode Persistence & Class Toggle // Dark Mode Persistence & Class Toggle
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@ -3820,6 +3821,7 @@ export default function WeeklyView() {
// Check if this is a recurring event — if so, ask before saving // Check if this is a recurring event — if so, ask before saving
const draggedEvent = calendarEvents.find(e => e.id === eventDragState.eventId); const draggedEvent = calendarEvents.find(e => e.id === eventDragState.eventId);
if (draggedEvent?.isRecurring) { if (draggedEvent?.isRecurring) {
setRecurringDragEditMode('this');
setPendingRecurringDrag({ setPendingRecurringDrag({
eventId: eventDragState.eventId, eventId: eventDragState.eventId,
calendarId: eventDragState.calendarId || '', calendarId: eventDragState.calendarId || '',
@ -3870,7 +3872,7 @@ export default function WeeklyView() {
}, [eventDragState, effectiveCellDuration, calendarEvents]); }, [eventDragState, effectiveCellDuration, calendarEvents]);
// Handle recurring event drag confirm (this/all) // Handle recurring event drag confirm (this/all)
const handleRecurringDragConfirm = async (editMode: 'this' | 'all') => { const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => {
if (!pendingRecurringDrag) return; if (!pendingRecurringDrag) return;
const { eventId, calendarId, recurringEventId, startTime, endTime, originalStartTime, originalEndTime } = pendingRecurringDrag; const { eventId, calendarId, recurringEventId, startTime, endTime, originalStartTime, originalEndTime } = pendingRecurringDrag;
try { try {
@ -9153,6 +9155,8 @@ export default function WeeklyView() {
initialDate={calendarEventModal.initialDate} initialDate={calendarEventModal.initialDate}
initialStartTime={calendarEventModal.initialStartTime} initialStartTime={calendarEventModal.initialStartTime}
connections={connections} connections={connections}
weekStartDay={weekStartDay}
language={language}
onClose={() => onClose={() =>
setCalendarEventModal({ ...calendarEventModal, isOpen: false }) setCalendarEventModal({ ...calendarEventModal, isOpen: false })
} }
@ -9166,57 +9170,58 @@ export default function WeeklyView() {
<div style={{ <div style={{
position: 'fixed', inset: 0, zIndex: 2000, position: 'fixed', inset: 0, zIndex: 2000,
display: 'flex', justifyContent: 'center', alignItems: 'center', display: 'flex', justifyContent: 'center', alignItems: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(2px)', background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(2px)',
}} }} onClick={handleRecurringDragCancel}>
onClick={handleRecurringDragCancel} <div style={{
>
<div
style={{
background: darkMode ? '#1e1e1e' : 'white', background: darkMode ? '#1e1e1e' : 'white',
color: darkMode ? '#eee' : '#333', color: darkMode ? '#eee' : '#333',
borderRadius: 12, padding: '20px 24px', borderRadius: 12, width: '90%', maxWidth: 420,
boxShadow: '0 8px 30px rgba(0,0,0,0.25)', boxShadow: '0 12px 40px rgba(0,0,0,0.25)',
minWidth: 260, textAlign: 'center', overflow: 'hidden',
}} }} onClick={e => e.stopPropagation()}>
onClick={e => e.stopPropagation()} <div style={{
> padding: '16px 20px 12px', fontWeight: 700, fontSize: '0.95rem',
<div style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 4 }}> borderBottom: '2px solid #3b82f6',
}}>
{language === 'de' ? 'Wiederkehrendes Ereignis bearbeiten' : 'Edit recurring event'} {language === 'de' ? 'Wiederkehrendes Ereignis bearbeiten' : 'Edit recurring event'}
</div> </div>
<div style={{ fontSize: '0.78rem', opacity: 0.7, marginBottom: 16 }}> <div style={{ padding: '12px 20px' }}>
{language === 'de' ? 'Änderung anwenden auf:' : 'Apply change to:'} {([
{ value: 'this' as const, title: language === 'de' ? 'Nur dieses Ereignis' : 'This event', desc: language === 'de' ? 'Alle anderen Ereignisse der Serie bleiben unverändert.' : 'All other events in the series stay the same.' },
{ value: 'future' as const, title: language === 'de' ? 'Dieses und folgende Ereignisse' : 'This and following events', desc: language === 'de' ? 'Dieses und alle zukünftigen Ereignisse der Serie werden geändert.' : 'This and all future events in the series will be changed.' },
{ value: 'all' as const, title: language === 'de' ? 'Alle Ereignisse' : 'All events', desc: language === 'de' ? 'Alle Ereignisse der Serie werden geändert.' : 'All events in the series will be changed.' },
]).map(opt => (
<label key={opt.value} style={{
display: 'flex', gap: 12, padding: '10px 4px', cursor: 'pointer',
borderBottom: `1px solid ${darkMode ? '#333' : '#eee'}`,
alignItems: 'flex-start',
}} onClick={() => setRecurringDragEditMode(opt.value)}>
<input
type="radio" name="recurringDragEditMode"
checked={recurringDragEditMode === opt.value}
onChange={() => setRecurringDragEditMode(opt.value)}
style={{ marginTop: 3, accentColor: '#3b82f6' }}
/>
<div>
<div style={{ fontWeight: 600, fontSize: '0.88rem' }}>{opt.title}</div>
<div style={{ fontSize: '0.78rem', opacity: 0.6, marginTop: 2 }}>{opt.desc}</div>
</div> </div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}> </label>
<button ))}
onClick={() => handleRecurringDragConfirm('this')} </div>
style={{ <div style={{
padding: '8px 16px', fontSize: '0.82rem', fontWeight: 600, padding: '12px 20px', display: 'flex', justifyContent: 'flex-end', gap: 8,
color: 'white', background: '#3b82f6', }}>
border: 'none', borderRadius: 8, cursor: 'pointer', <button onClick={handleRecurringDragCancel} style={{
}} padding: '8px 16px', fontSize: '0.85rem', fontWeight: 600,
> background: 'none', border: 'none', cursor: 'pointer',
{language === 'de' ? 'Nur dieses' : 'This event'} color: darkMode ? '#aaa' : '#666',
</button> }}>{language === 'de' ? 'Abbrechen' : 'Cancel'}</button>
<button <button onClick={() => handleRecurringDragConfirm(recurringDragEditMode)} style={{
onClick={() => handleRecurringDragConfirm('all')} padding: '8px 20px', fontSize: '0.85rem', fontWeight: 600,
style={{ background: '#3b82f6', color: 'white', border: 'none',
padding: '8px 16px', fontSize: '0.82rem', fontWeight: 600, borderRadius: 8, cursor: 'pointer',
color: 'white', background: '#6366f1', }}>{language === 'de' ? 'Speichern' : 'Save'}</button>
border: 'none', borderRadius: 8, cursor: 'pointer',
}}
>
{language === 'de' ? 'Alle Ereignisse' : 'All events'}
</button>
<button
onClick={handleRecurringDragCancel}
style={{
padding: '8px 16px', fontSize: '0.82rem', fontWeight: 500,
color: darkMode ? '#aaa' : '#666', background: darkMode ? '#333' : '#f0f0f0',
border: 'none', borderRadius: 8, cursor: 'pointer',
}}
>
{language === 'de' ? 'Abbrechen' : 'Cancel'}
</button>
</div> </div>
</div> </div>
</div> </div>