3961 lines
207 KiB
TypeScript
3961 lines
207 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect, useRef, useCallback, useMemo, DragEvent } from 'react';
|
||
import { useSession, signOut } from 'next-auth/react';
|
||
import CalendarEventModal from './CalendarEventModal';
|
||
import TaskRecurrenceModal from './RecurrenceModal';
|
||
|
||
import FocusModeOverlay from './FocusModeOverlay';
|
||
import {
|
||
LayoutGrid,
|
||
Calendar,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
Search,
|
||
Settings,
|
||
User,
|
||
Clock,
|
||
Menu,
|
||
Target,
|
||
Sun,
|
||
Moon
|
||
} from 'lucide-react';
|
||
|
||
// Types
|
||
import UserMenu from './UserMenu';
|
||
import SearchModal from './SearchModal';
|
||
import SimpleDatePicker from './SimpleDatePicker';
|
||
import RecurringTasksManager from './RecurringTasksManager';
|
||
|
||
interface Task {
|
||
id: string;
|
||
title: string;
|
||
markdownContent?: string;
|
||
completed: boolean;
|
||
dayOfWeek?: number | null;
|
||
scheduledDate?: string | null;
|
||
somedayListId?: string | null;
|
||
order: number;
|
||
startTime?: string | null;
|
||
endTime?: string | null;
|
||
userId: string;
|
||
isRolling?: boolean;
|
||
isRecurring?: boolean;
|
||
recurrenceInterval?: number | null;
|
||
recurrenceUnit?: string | null;
|
||
recurrenceTime?: string | null;
|
||
recurrenceEndDate?: Date | null;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}
|
||
|
||
interface CalendarEvent {
|
||
id: string;
|
||
title: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
source: 'google' | 'apple';
|
||
calendarId?: string;
|
||
calendarTitle?: string;
|
||
calendarColor?: string;
|
||
editable?: boolean;
|
||
}
|
||
|
||
interface SomedayList {
|
||
id: string;
|
||
title: string;
|
||
tasks: Task[];
|
||
}
|
||
|
||
// Time grid configuration options
|
||
type CellDuration = 15 | 30 | 60 | 120;
|
||
|
||
// Translations
|
||
const translations: Record<string, any> = {
|
||
en: {
|
||
settings: 'Settings',
|
||
general: 'General',
|
||
calendar: 'Calendar',
|
||
account: 'Account',
|
||
runningList: 'Running List (Auto-roll tasks to today)',
|
||
protectEventTimes: 'Protect Event Times',
|
||
showTimeGrid: 'Show Time Grid',
|
||
timeSlotDuration: 'Time Slot Duration',
|
||
viewStyle: 'View Style',
|
||
gridView: 'Grid View',
|
||
listView: 'List View',
|
||
language: 'Language',
|
||
dateFormat: 'Date Format',
|
||
timeFormat: 'Time Format',
|
||
saveChanges: 'Save Changes',
|
||
connectedCalendars: 'Connected Calendars',
|
||
connectMore: 'Connect More',
|
||
connectGoogle: 'Connect Google Calendar',
|
||
connectApple: 'Connect Apple Calendar',
|
||
noCalendars: 'No calendars connected yet.',
|
||
dataPrivacy: 'Data & Privacy',
|
||
downloadData: 'Download My Data',
|
||
deleteAccount: 'Delete Account',
|
||
name: 'Name',
|
||
email: 'Email',
|
||
timezone: 'Timezone',
|
||
changePassword: 'Change Password',
|
||
newPassword: 'New Password',
|
||
confirmPassword: 'Confirm Password',
|
||
someday: 'SOMEDAY',
|
||
lists: 'Lists',
|
||
loading: 'Loading your tasks...',
|
||
sycing: 'Syncing...',
|
||
synced: 'Synced',
|
||
localization: 'Localization',
|
||
allDayEvents: 'ALL-DAY EVENTS',
|
||
syncCalendar: 'Sync Calendar',
|
||
toggleDarkMode: 'Toggle Dark Mode',
|
||
signOut: 'Sign Out',
|
||
startHour: 'Start of Day',
|
||
endHour: 'End of Day',
|
||
weekAbbr: 'W',
|
||
mottoOfWeek: 'Motto of the Week',
|
||
showSomeday: 'Show Someday Section',
|
||
showAllDay: 'Show All-Day Section'
|
||
},
|
||
de: {
|
||
settings: 'Einstellungen',
|
||
general: 'Allgemein',
|
||
calendar: 'Kalender',
|
||
account: 'Konto',
|
||
runningList: 'Laufende Liste (Aufgaben automatisch auf heute verschieben)',
|
||
protectEventTimes: 'Ereigniszeiten schützen',
|
||
showTimeGrid: 'Zeitplan anzeigen',
|
||
timeSlotDuration: 'Zeitfensterdauer',
|
||
viewStyle: 'Ansichtsstil',
|
||
gridView: 'Rasteransicht',
|
||
listView: 'Listenansicht',
|
||
language: 'Sprache',
|
||
dateFormat: 'Datumsformat',
|
||
timeFormat: 'Zeitformat',
|
||
saveChanges: 'Änderungen speichern',
|
||
connectedCalendars: 'Verbundene Kalender',
|
||
connectMore: 'Mehr verbinden',
|
||
connectGoogle: 'Google Kalender verbinden',
|
||
connectApple: 'Apple Kalender verbinden',
|
||
noCalendars: 'Keine Kalender verbunden.',
|
||
dataPrivacy: 'Daten & Datenschutz',
|
||
downloadData: 'Meine Daten herunterladen',
|
||
deleteAccount: 'Konto löschen',
|
||
name: 'Name',
|
||
email: 'E-Mail',
|
||
timezone: 'Zeitzone',
|
||
changePassword: 'Passwort ändern',
|
||
newPassword: 'Neues Passwort',
|
||
confirmPassword: 'Passwort bestätigen',
|
||
someday: 'IRGENDWANN',
|
||
lists: 'Listen',
|
||
loading: 'Lade Aufgaben...',
|
||
syncing: 'Synchronisiere...',
|
||
synced: 'Synchronisiert',
|
||
localization: 'Lokalisierung',
|
||
allDayEvents: 'GANZTÄGIGE EREIGNISSE',
|
||
syncCalendar: 'Kalender synchronisieren',
|
||
toggleDarkMode: 'Dunkelmodus umschalten',
|
||
signOut: 'Abmelden',
|
||
startHour: 'Tagesbeginn',
|
||
endHour: 'Tagesende',
|
||
weekAbbr: 'KW',
|
||
mottoOfWeek: 'Motto der Woche',
|
||
showSomeday: 'Irgendwann-Bereich anzeigen',
|
||
showAllDay: 'Ganztägige Ereignisse anzeigen'
|
||
}
|
||
};
|
||
|
||
// Date utilities
|
||
function getStartOfWeek(date: Date, startDay: number = 0): Date {
|
||
const d = new Date(date);
|
||
const day = d.getDay();
|
||
const diff = d.getDate() - day + startDay;
|
||
return new Date(d.setDate(diff));
|
||
}
|
||
|
||
function formatDateHeader(date: Date, locale: string = 'en-US'): string {
|
||
return date.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' });
|
||
}
|
||
|
||
function getDayName(date: Date, locale: string = 'en-US'): string {
|
||
return date.toLocaleDateString(locale, { weekday: 'long' }).toUpperCase();
|
||
}
|
||
|
||
function isSameDay(d1: Date, d2: Date): boolean {
|
||
return d1.toDateString() === d2.toDateString();
|
||
}
|
||
|
||
function formatDateToISO(date: Date): string {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function formatHour(hour: number): string {
|
||
return `${hour}`;
|
||
}
|
||
|
||
function getTimeSlots(cellDuration: CellDuration, startHour: number, endHour: number): string[] {
|
||
const slots: string[] = [];
|
||
const slotsPerHour = 60 / cellDuration;
|
||
for (let hour = startHour; hour < endHour; hour++) {
|
||
for (let slot = 0; slot < slotsPerHour; slot++) {
|
||
const minutes = slot * cellDuration;
|
||
slots.push(`${hour.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
||
}
|
||
}
|
||
return slots;
|
||
}
|
||
|
||
function getHourFromSlot(slot: string): number {
|
||
return parseInt(slot.split(':')[0], 10);
|
||
}
|
||
|
||
function getWeekNumber(date: Date): number {
|
||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||
const dayNum = d.getUTCDay() || 7;
|
||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||
}
|
||
|
||
// Check if an event is an all-day event
|
||
// Defined outside component to avoid stale closure issues in useCallbacks
|
||
const isAllDayEvent = (event: CalendarEvent): boolean => {
|
||
if (!event.startTime) return false;
|
||
|
||
// Date-only format (YYYY-MM-DD)
|
||
if (!event.startTime.includes('T')) return true;
|
||
|
||
const start = new Date(event.startTime);
|
||
const end = new Date(event.endTime);
|
||
const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
||
|
||
// Check if strictly midnight to midnight in local time
|
||
const isLocalMidnight = start.getHours() === 0 && start.getMinutes() === 0;
|
||
|
||
// Check if UTC midnight (common for API-converted date strings)
|
||
const isUTCMidnight = start.getUTCHours() === 0 && start.getUTCMinutes() === 0;
|
||
|
||
// If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day
|
||
return durationHours >= 24 && (isLocalMidnight || isUTCMidnight);
|
||
};
|
||
|
||
// Main Component
|
||
export default function WeeklyView() {
|
||
const { data: session } = useSession();
|
||
const [tasks, setTasks] = useState<Task[]>([]);
|
||
const [connections, setConnections] = useState<any[]>([]); // Lifted state
|
||
const [rawCalendarEvents, setRawCalendarEvents] = useState<CalendarEvent[]>([]);
|
||
|
||
// Extend events with editable flag from connections
|
||
const calendarEvents = useMemo(() => {
|
||
return rawCalendarEvents.map(event => {
|
||
let isEditable = false;
|
||
if (event.calendarId) {
|
||
for (const conn of connections) {
|
||
if (conn.calendars && Array.isArray(conn.calendars)) {
|
||
const cal = conn.calendars.find((c: any) => c.id === event.calendarId);
|
||
if (cal && cal.editable) {
|
||
isEditable = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { ...event, editable: isEditable };
|
||
});
|
||
}, [rawCalendarEvents, connections]);
|
||
const [currentWeekStart, setCurrentWeekStart] = useState(getStartOfWeek(new Date()));
|
||
const [viewDays, setViewDays] = useState(7);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [darkMode, setDarkMode] = useState(false);
|
||
const [timeFormat, setTimeFormat] = useState('24h');
|
||
const [dateFormat, setDateFormat] = useState('yyyy-MM-dd');
|
||
|
||
const [somedayExpanded, setSomedayExpanded] = useState(true);
|
||
const [draggingListId, setDraggingListId] = useState<string | null>(null);
|
||
const [isAllDayExpanded, setIsAllDayExpanded] = useState(true);
|
||
const [somedayLists, setSomedayLists] = useState<SomedayList[]>([]);
|
||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||
const [showSettings, setShowSettings] = useState(false);
|
||
const [showPreferences, setShowPreferences] = useState(false);
|
||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||
const [isAddingSomedayList, setIsAddingSomedayList] = useState(false);
|
||
const [newSomedayListName, setNewSomedayListName] = useState('');
|
||
const [language, setLanguage] = useState('de');
|
||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'synced'>('idle');
|
||
const [cellDuration, setCellDuration] = useState<CellDuration>(60);
|
||
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
|
||
const [showTimeGrid, setShowTimeGrid] = useState(true);
|
||
const [slideDirection, setSlideDirection] = useState<'left' | 'right' | 'out-left' | 'out-right' | 'in-left' | 'in-right' | null>(null);
|
||
const [activeSlot, setActiveSlot] = useState<{ day: number; slot: string } | null>(null);
|
||
const [newSlotTask, setNewSlotTask] = useState('');
|
||
const [selectedTaskForNotes, setSelectedTaskForNotes] = useState<Task | null>(null);
|
||
const [currentTime, setCurrentTime] = useState(new Date());
|
||
const [dropPreview, setDropPreview] = useState<{ day: number; slot: string } | null>(null);
|
||
const [viewStyle, setViewStyle] = useState<'grid' | 'list'>('list');
|
||
const [protectEventTimes, setProtectEventTimes] = useState(true);
|
||
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
|
||
|
||
const [startHour, setStartHour] = useState(8);
|
||
const [endHour, setEndHour] = useState(22);
|
||
const [showSomeday, setShowSomeday] = useState(true);
|
||
const [showAllDay, setShowAllDay] = useState(true);
|
||
const [motto, setMotto] = useState('Focus and Execute');
|
||
const [isEditingMotto, setIsEditingMotto] = useState(false);
|
||
const [showNextTask, setShowNextTask] = useState(false);
|
||
const [calendarEditMode, setCalendarEditMode] = useState(false);
|
||
const [selectedTaskForRecurrence, setSelectedTaskForRecurrence] = useState<Task | null>(null);
|
||
const [showFocusMode, setShowFocusMode] = useState(false);
|
||
|
||
// New UI State
|
||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||
const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false);
|
||
|
||
const [focusTimerDuration, setFocusTimerDuration] = useState(25);
|
||
const [fontSize, setFontSize] = useState<'S' | 'M' | 'L'>('M');
|
||
|
||
// Calendar Event Modal State
|
||
const [calendarEventModal, setCalendarEventModal] = useState<{
|
||
isOpen: boolean;
|
||
event?: CalendarEvent;
|
||
initialDate?: Date;
|
||
initialStartTime?: string;
|
||
}>({ isOpen: false });
|
||
|
||
// Dark Mode Persistence & Class Toggle
|
||
const [mounted, setMounted] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
const savedDarkMode = localStorage.getItem('weekly-dark-mode');
|
||
if (savedDarkMode) {
|
||
setDarkMode(JSON.parse(savedDarkMode));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!mounted) return;
|
||
localStorage.setItem('weekly-dark-mode', JSON.stringify(darkMode));
|
||
if (darkMode) {
|
||
document.documentElement.classList.add('dark');
|
||
} else {
|
||
document.documentElement.classList.remove('dark');
|
||
}
|
||
}, [darkMode, mounted]);
|
||
|
||
// Translation helper
|
||
const t = translations[language] || translations['en'];
|
||
|
||
// Refs for scroll synchronization
|
||
const timeColumnRef = useRef<HTMLDivElement>(null);
|
||
const dayColumnsRef = useRef<HTMLDivElement[]>([]);
|
||
const isScrollSyncing = useRef(false);
|
||
|
||
// Scroll sync handler
|
||
const handleTimeColumnScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||
if (isScrollSyncing.current) return;
|
||
isScrollSyncing.current = true;
|
||
const scrollTop = e.currentTarget.scrollTop;
|
||
dayColumnsRef.current.forEach(col => {
|
||
if (col) col.scrollTop = scrollTop;
|
||
});
|
||
setTimeout(() => { isScrollSyncing.current = false; }, 10);
|
||
};
|
||
|
||
const handleDayColumnScroll = (e: React.UIEvent<HTMLDivElement>, index: number) => {
|
||
if (isScrollSyncing.current) return;
|
||
isScrollSyncing.current = true;
|
||
const scrollTop = e.currentTarget.scrollTop;
|
||
if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollTop;
|
||
dayColumnsRef.current.forEach((col, i) => {
|
||
if (col && i !== index) col.scrollTop = scrollTop;
|
||
});
|
||
setTimeout(() => { isScrollSyncing.current = false; }, 10);
|
||
};
|
||
|
||
// Slot height based on cell duration
|
||
const getSlotHeight = (duration: CellDuration) => {
|
||
switch (duration) {
|
||
case 15: return 25;
|
||
case 30: return 35;
|
||
case 60: return 50;
|
||
case 120: return 80;
|
||
default: return 50;
|
||
}
|
||
};
|
||
|
||
// Header height based on cell duration for alignment
|
||
const getHeaderHeight = (duration: CellDuration) => {
|
||
switch (duration) {
|
||
case 15: return 65;
|
||
case 30: return 55;
|
||
case 60: return 40;
|
||
case 120: return 40;
|
||
default: return 40;
|
||
}
|
||
};
|
||
|
||
// Working hours range (configurable)
|
||
const workingHoursStart = startHour;
|
||
const workingHoursEnd = endHour;
|
||
|
||
// Fetch calendar events
|
||
const fetchCalendarEvents = useCallback(async () => {
|
||
try {
|
||
const response = await fetch('/api/calendar/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
timeMin: currentWeekStart.toISOString(),
|
||
timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.events) {
|
||
setRawCalendarEvents(data.events);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching calendar events:', error);
|
||
}
|
||
}, [currentWeekStart]);
|
||
|
||
// Calendar Event Handlers
|
||
const handleEventSave = async (eventData: any) => {
|
||
try {
|
||
const method = eventData.id ? 'PATCH' : 'POST';
|
||
const body = {
|
||
...eventData,
|
||
eventId: eventData.id // For PATCH
|
||
};
|
||
|
||
const res = await fetch('/api/calendar/events', {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || 'Failed to save event');
|
||
}
|
||
|
||
// Refresh events
|
||
await fetchCalendarEvents();
|
||
} catch (error) {
|
||
console.error('Error saving event:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const handleEventDelete = async (eventId: string, calendarId: string) => {
|
||
try {
|
||
const res = await fetch(`/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || 'Failed to delete event');
|
||
}
|
||
|
||
// Refresh events
|
||
await fetchCalendarEvents();
|
||
} catch (error) {
|
||
console.error('Error deleting event:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const handleRecurrenceSave = async (taskId: string, recurrence: any) => {
|
||
try {
|
||
const res = await fetch('/api/tasks', { // Uses PATCH endpoint which handles ID in body
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: taskId,
|
||
...recurrence
|
||
})
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error('Failed to update recurrence');
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
// Update local state
|
||
setTasks(prev => prev.map(t => t.id === taskId ? data.task : t));
|
||
} catch (error) {
|
||
console.error(error);
|
||
alert('Failed to save recurrence settings');
|
||
}
|
||
};
|
||
|
||
// Fetch tasks on mount
|
||
useEffect(() => {
|
||
if (session) {
|
||
fetchTasks();
|
||
fetchConnections(); // Fetch connections
|
||
fetchCalendarEvents();
|
||
}
|
||
}, [session]);
|
||
|
||
async function fetchConnections() {
|
||
try {
|
||
const response = await fetch('/api/calendar/connections');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setConnections(data.connections || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching connections:', error);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
// Refetch calendar events when week changes
|
||
useEffect(() => {
|
||
if (session) {
|
||
fetchCalendarEvents();
|
||
}
|
||
}, [currentWeekStart, session, fetchCalendarEvents]);
|
||
|
||
// Update current time every minute for the "Now" line
|
||
useEffect(() => {
|
||
const interval = setInterval(() => {
|
||
setCurrentTime(new Date());
|
||
}, 60000); // Update every minute
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
const handleSettingsChanged = (newSettings: {
|
||
showTimeGrid: boolean;
|
||
cellDuration: CellDuration;
|
||
viewStyle: 'grid' | 'list';
|
||
language: string;
|
||
dateFormat: string;
|
||
timeFormat: string;
|
||
startHour: number;
|
||
endHour: number;
|
||
fontSize: 'S' | 'M' | 'L';
|
||
showNextTask: boolean;
|
||
}) => {
|
||
setShowTimeGrid(newSettings.showTimeGrid);
|
||
setCellDuration(newSettings.cellDuration);
|
||
setViewStyle(newSettings.viewStyle);
|
||
setLanguage(newSettings.language);
|
||
setDateFormat(newSettings.dateFormat);
|
||
setTimeFormat(newSettings.timeFormat);
|
||
setStartHour(newSettings.startHour);
|
||
setEndHour(newSettings.endHour);
|
||
setFontSize(newSettings.fontSize);
|
||
setShowNextTask(newSettings.showNextTask);
|
||
// Custom start/end hours might affect task placement if we filter strictly
|
||
// But mainly we just re-render. Fetching tasks again isn't strictly necessary unless filtering changed on backend.
|
||
// But let's do it to be safe if backend filtering relies on these.
|
||
fetchTasks();
|
||
};
|
||
|
||
const fetchUserInfo = async () => {
|
||
try {
|
||
const res = await fetch('/api/user/profile');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.user) {
|
||
setProtectEventTimes(data.user.protectEventTimes || false);
|
||
setTimeFormat(data.user.timeFormat || '12h');
|
||
setDateFormat(data.user.dateFormat || 'MM/dd/yyyy');
|
||
setLanguage(data.user.language || 'en');
|
||
if (data.user.startHour !== undefined) setStartHour(data.user.startHour);
|
||
if (data.user.endHour !== undefined) setEndHour(data.user.endHour);
|
||
setShowNextTask(data.user.showNextTask || false);
|
||
setCalendarEditMode(data.user.calendarEditMode || false);
|
||
if (data.user.fontSize) setFontSize(data.user.fontSize as 'S' | 'M' | 'L');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchUserInfo();
|
||
}, []);
|
||
|
||
async function fetchSomedayLists() {
|
||
try {
|
||
const response = await fetch('/api/someday-lists');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
// Map tasks is handled in fetchTasks or we can merge here if needed.
|
||
// But fetchTasks fetches ALL tasks.
|
||
// Optimally we fetch lists, then tasks, then merge.
|
||
// For now, let's just set the lists structure.
|
||
setSomedayLists(data.lists.map((l: any) => ({
|
||
id: l.id,
|
||
title: l.title,
|
||
tasks: l.tasks || [] // Tasks will be overwritten/populated by fetchTasks
|
||
})));
|
||
return data.lists;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching someday lists:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function fetchTasks() {
|
||
try {
|
||
const [tasksResponse, listsResponse] = await Promise.all([
|
||
fetch('/api/tasks'),
|
||
fetch('/api/someday-lists') // Fetch lists in parallel
|
||
]);
|
||
|
||
let fetchedLists: SomedayList[] = [];
|
||
if (listsResponse.ok) {
|
||
const listData = await listsResponse.json();
|
||
fetchedLists = listData.lists.map((l: any) => ({
|
||
id: l.id,
|
||
title: l.title,
|
||
tasks: []
|
||
}));
|
||
}
|
||
|
||
// If no lists exist, maybe create default 'Someday'?
|
||
// TeuxDeux usually starts with one.
|
||
// If DB is empty, maybe create one?
|
||
// For now, if empty, we might show empty.
|
||
if (fetchedLists.length === 0) {
|
||
// Optionally create default list if none exist?
|
||
// Let's stick to what's in DB.
|
||
}
|
||
|
||
if (tasksResponse.ok) {
|
||
const data = await tasksResponse.json();
|
||
const fetchedTasks = data.tasks.map((t: any) => ({
|
||
...t,
|
||
createdAt: new Date(t.createdAt),
|
||
updatedAt: new Date(t.updatedAt),
|
||
}));
|
||
|
||
const dayTasks = fetchedTasks.filter((t: Task) => t.dayOfWeek !== null && !t.somedayListId);
|
||
const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId);
|
||
|
||
setTasks(dayTasks);
|
||
|
||
// Populate lists with tasks
|
||
const populatedLists = fetchedLists.map(list => ({
|
||
...list,
|
||
tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id)
|
||
}));
|
||
|
||
// Fallback: If there are someday tasks with IDs that don't match any list (orphans),
|
||
// or if we rely on the old "default" list for legacy data.
|
||
// The old code had a default list.
|
||
// Let's ensure we use the fetched lists.
|
||
setSomedayLists(populatedLists);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching data:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
// Get visible days based on current view setting
|
||
const getVisibleDays = useCallback(() => {
|
||
const days: Date[] = [];
|
||
for (let i = 0; i < viewDays; i++) {
|
||
days.push(new Date(currentWeekStart.getTime() + i * 24 * 60 * 60 * 1000));
|
||
}
|
||
return days;
|
||
}, [currentWeekStart, viewDays]);
|
||
|
||
// Get tasks for a specific date
|
||
const getTasksForDate = useCallback((date: Date): Task[] => {
|
||
const dateStr = formatDateToISO(date); // Use local date formatting
|
||
return tasks
|
||
.filter(task => {
|
||
if (!task.scheduledDate) return false;
|
||
const taskDateStr = formatDateToISO(new Date(task.scheduledDate));
|
||
return taskDateStr === dateStr;
|
||
})
|
||
.sort((a, b) => {
|
||
// Sort by time if available
|
||
if (a.startTime && b.startTime) {
|
||
return a.startTime.localeCompare(b.startTime);
|
||
}
|
||
if (a.startTime) return -1;
|
||
if (b.startTime) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
}, [tasks]);
|
||
|
||
// Get tasks for a specific time slot
|
||
const getTasksForSlot = useCallback((date: Date, slot: string): Task[] => {
|
||
const dateStr = formatDateToISO(date);
|
||
return tasks.filter(task => {
|
||
if (!task.scheduledDate) return false;
|
||
const taskDateStr = formatDateToISO(new Date(task.scheduledDate));
|
||
return taskDateStr === dateStr && task.startTime === slot;
|
||
});
|
||
}, [tasks]);
|
||
|
||
// Get calendar events for a specific date
|
||
const getEventsForDate = useCallback((date: Date): CalendarEvent[] => {
|
||
return calendarEvents.filter(event => {
|
||
// Skip all-day events (handled separately)
|
||
if (isAllDayEvent(event)) return false;
|
||
|
||
const eventDate = new Date(event.startTime);
|
||
return isSameDay(eventDate, date);
|
||
});
|
||
}, [calendarEvents]);
|
||
|
||
// Get calendar events for a specific time slot
|
||
const getEventsForSlot = useCallback((date: Date, slot: string): CalendarEvent[] => {
|
||
return calendarEvents.filter(event => {
|
||
// Skip all-day events (handled separately)
|
||
const isAllDay = isAllDayEvent(event);
|
||
if (isAllDay) return false;
|
||
|
||
if (event.title.includes('Valentinstag')) {
|
||
// Debug removed
|
||
}
|
||
|
||
const eventDate = new Date(event.startTime);
|
||
if (!isSameDay(eventDate, date)) return false;
|
||
|
||
// Extract hour:minute from event start time and compare with slot
|
||
const eventHour = eventDate.getHours();
|
||
const eventMinute = eventDate.getMinutes();
|
||
|
||
// Match if event starts within this slot
|
||
const [slotHour, slotMinute] = slot.split(':').map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
const slotEnd = slotStart + cellDuration;
|
||
const eventStart = eventHour * 60 + eventMinute;
|
||
|
||
return eventStart >= slotStart && eventStart < slotEnd;
|
||
});
|
||
}, [calendarEvents, cellDuration]);
|
||
|
||
// Calculate event duration in pixels for proper height display
|
||
const getEventDuration = (event: CalendarEvent): number => {
|
||
if (isAllDayEvent(event)) return 0; // All-day events handled separately
|
||
|
||
const start = new Date(event.startTime);
|
||
const end = new Date(event.endTime);
|
||
const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
|
||
|
||
// Calculate height based on duration and slot height
|
||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||
return Math.max(durationMinutes * pixelsPerMinute, getSlotHeight(cellDuration));
|
||
};
|
||
|
||
// Get all-day events for a specific date
|
||
const getAllDayEventsForDate = useCallback((date: Date): CalendarEvent[] => {
|
||
return calendarEvents.filter(event => {
|
||
if (!isAllDayEvent(event)) return false;
|
||
|
||
// Parse date from startTime
|
||
const eventStart = new Date(event.startTime);
|
||
const eventEnd = event.endTime ? new Date(event.endTime) : new Date(eventStart);
|
||
|
||
// Normalize dates to start of day for comparison
|
||
const targetDate = new Date(date);
|
||
targetDate.setHours(0, 0, 0, 0);
|
||
|
||
const start = new Date(eventStart);
|
||
start.setHours(0, 0, 0, 0);
|
||
|
||
const end = new Date(eventEnd);
|
||
end.setHours(0, 0, 0, 0);
|
||
|
||
// If strictly dates, often end date is exclusive or same day?
|
||
// Google Calendar all-day events: end date is exclusive (e.g. starts 2023-01-01, ends 2023-01-02 for 1 day).
|
||
// If start == end, it's 1 day (but usually GCal sends next day).
|
||
// Let's assume inclusive start, exclusive end logic or "overlaps" logic.
|
||
// Check if targetDate is >= start AND targetDate < end
|
||
|
||
// Handle single day case where start == end or end is not provided
|
||
if (!event.endTime || start.getTime() === end.getTime()) {
|
||
return start.getTime() === targetDate.getTime();
|
||
}
|
||
|
||
return targetDate.getTime() >= start.getTime() && targetDate.getTime() < end.getTime();
|
||
});
|
||
}, [calendarEvents]);
|
||
|
||
// Get all all-day events for the visible week
|
||
const getAllDayEventsForWeek = useCallback((): Map<string, CalendarEvent[]> => {
|
||
const eventsByDay = new Map<string, CalendarEvent[]>();
|
||
const visibleDays = getVisibleDays();
|
||
|
||
visibleDays.forEach(date => {
|
||
const dateKey = formatDateToISO(date);
|
||
eventsByDay.set(dateKey, getAllDayEventsForDate(date));
|
||
});
|
||
|
||
return eventsByDay;
|
||
}, [calendarEvents, currentWeekStart, viewDays]);
|
||
|
||
// Check if a slot is protected by calendar events (only if slot starts within event time range)
|
||
const isSlotProtected = useCallback((date: Date, slot: string): boolean => {
|
||
if (!protectEventTimes) return false;
|
||
|
||
const [slotHour, slotMinute] = slot.split(':').map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
|
||
return calendarEvents.some(event => {
|
||
if (isAllDayEvent(event)) return false;
|
||
// Skip events that have been unlocked by the user
|
||
if (unlockedEvents.has(event.id)) return false;
|
||
|
||
const eventDate = new Date(event.startTime);
|
||
if (!isSameDay(eventDate, date)) return false;
|
||
|
||
const eventStart = eventDate.getHours() * 60 + eventDate.getMinutes();
|
||
const eventEndDate = new Date(event.endTime);
|
||
const eventEndMinutes = eventEndDate.getHours() * 60 + eventEndDate.getMinutes();
|
||
|
||
// Only protect if the slot start time falls within the event's actual duration
|
||
// This ensures protection matches exactly what the event covers
|
||
return slotStart >= eventStart && slotStart < eventEndMinutes;
|
||
});
|
||
}, [protectEventTimes, calendarEvents, unlockedEvents]);
|
||
|
||
// Navigation handlers with proper slide animation
|
||
// Simplified: Immediate state update with slide-in animation to prevent blank flash
|
||
const navigate = (newDate: Date, direction: 'left' | 'right', type: 'day' | 'week') => {
|
||
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
|
||
const doc = document as any;
|
||
doc.documentElement.dataset.transitionDirection = direction === 'left' ? 'next' : 'prev';
|
||
doc.documentElement.dataset.navType = 'week'; // Always use full-grid slide for both day and week
|
||
|
||
doc.startViewTransition(() => {
|
||
setCurrentWeekStart(newDate);
|
||
setSlideDirection(null);
|
||
});
|
||
} else {
|
||
setCurrentWeekStart(newDate);
|
||
}
|
||
};
|
||
|
||
const goToPrevWeek = () => navigate(new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000), 'right', 'week');
|
||
const goToNextWeek = () => navigate(new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000), 'left', 'week');
|
||
const goToPrevDay = () => navigate(new Date(currentWeekStart.getTime() - 24 * 60 * 60 * 1000), 'right', 'day');
|
||
const goToNextDay = () => navigate(new Date(currentWeekStart.getTime() + 24 * 60 * 60 * 1000), 'left', 'day');
|
||
const goToToday = () => setCurrentWeekStart(getStartOfWeek(new Date()));
|
||
|
||
// Task CRUD operations
|
||
const addTask = async (date: Date, title: string, startTime?: string) => {
|
||
if (!title.trim()) return;
|
||
|
||
const scheduledDate = formatDateToISO(date); // Use local date formatting
|
||
|
||
if (!session?.user) {
|
||
// Local-only demo mode when not authenticated
|
||
const tempId = `temp-${Date.now()}`;
|
||
setTasks(prevTasks => [...prevTasks, {
|
||
id: tempId,
|
||
title: title.trim(),
|
||
dayOfWeek: date.getDay(),
|
||
scheduledDate,
|
||
order: prevTasks.filter(t => t.scheduledDate === scheduledDate).length,
|
||
completed: false,
|
||
userId: 'temp',
|
||
startTime,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
}]);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: title.trim(),
|
||
dayOfWeek: date.getDay(),
|
||
scheduledDate,
|
||
order: 0,
|
||
startTime
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setTasks(prevTasks => [...prevTasks, {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
}]);
|
||
} else {
|
||
console.error('Failed to add task:', await response.text());
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding task:', error);
|
||
}
|
||
};
|
||
|
||
const toggleTask = async (taskId: string) => {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
const updatedCompleted = !task.completed;
|
||
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, completed: updatedCompleted, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, completed: updatedCompleted }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error toggling task:', error);
|
||
}
|
||
};
|
||
|
||
const updateTask = async (taskId: string, newTitle: string) => {
|
||
if (!newTitle.trim()) {
|
||
await deleteTask(taskId);
|
||
return;
|
||
}
|
||
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, title: newTitle.trim(), updatedAt: new Date() }
|
||
: t
|
||
));
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, title: newTitle.trim() }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating task:', error);
|
||
}
|
||
};
|
||
|
||
const updateTaskFields = async (taskId: string, fields: Partial<Task>) => {
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, ...fields, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
// Also update someday lists if the task is there
|
||
setSomedayLists(lists => lists.map(list => ({
|
||
...list,
|
||
tasks: list.tasks.map(t => t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t)
|
||
})));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, ...fields }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating task fields:', error);
|
||
}
|
||
};
|
||
|
||
const updateTaskDuration = async (taskId: string, durationMinutes: number) => {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task || !task.startTime) return;
|
||
|
||
try {
|
||
// Parse start time (HH:mm)
|
||
const [startHour, startMinute] = task.startTime.split(':').map(Number);
|
||
|
||
// Calculate end time
|
||
const totalStartMinutes = startHour * 60 + startMinute;
|
||
const totalEndMinutes = totalStartMinutes + durationMinutes;
|
||
|
||
const endHour = Math.floor(totalEndMinutes / 60) % 24; // Wrap around 24h
|
||
const endMinute = totalEndMinutes % 60;
|
||
|
||
const endTimeStr = `${endHour.toString().padStart(2, '0')}:${endMinute.toString().padStart(2, '0')}`;
|
||
|
||
// Optimistic update
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, endTime: endTimeStr, updatedAt: new Date() } : t
|
||
));
|
||
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, endTime: endTimeStr }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating task duration:', error);
|
||
}
|
||
};
|
||
|
||
const updateTaskNotes = async (taskId: string, notes: string) => {
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, markdownContent: notes, updatedAt: new Date() } : t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, markdownContent: notes }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating task notes:', error);
|
||
}
|
||
};
|
||
|
||
const toggleTaskRolling = async (taskId: string) => {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
const newRollingState = !task.isRolling;
|
||
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, isRolling: newRollingState }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating task rolling state:', error);
|
||
// Revert on error
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, isRolling: !newRollingState } : t
|
||
));
|
||
}
|
||
};
|
||
|
||
const moveTaskToSlot = async (taskId: string, dayOfWeek: number, startTime: string, scheduledDate?: Date) => {
|
||
const newScheduledDate = scheduledDate ? formatDateToISO(scheduledDate) : undefined;
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, dayOfWeek, startTime, scheduledDate: newScheduledDate || t.scheduledDate, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, dayOfWeek, startTime, scheduledDate: newScheduledDate }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error moving task:', error);
|
||
}
|
||
};
|
||
|
||
const deleteTask = async (taskId: string) => {
|
||
setTasks(tasks.filter(t => t.id !== taskId));
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
await fetch(`/api/tasks?id=${taskId}`, { method: 'DELETE' });
|
||
} catch (error) {
|
||
console.error('Error deleting task:', error);
|
||
}
|
||
};
|
||
|
||
// Toggle rolling status
|
||
const toggleRolling = async (taskId: string) => {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
const updatedIsRolling = !task.isRolling;
|
||
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, isRolling: updatedIsRolling, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, isRolling: updatedIsRolling }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error toggling rolling status:', error);
|
||
}
|
||
};
|
||
|
||
// Roll task to tomorrow or next week
|
||
const rollTask = async (taskId: string, rollType: 'tomorrow' | 'nextWeek') => {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task || task.completed) return;
|
||
|
||
// Get current task date
|
||
const currentDate = task.scheduledDate ? new Date(task.scheduledDate) : new Date();
|
||
|
||
// Calculate new date
|
||
const newDate = new Date(currentDate);
|
||
if (rollType === 'tomorrow') {
|
||
newDate.setDate(newDate.getDate() + 1);
|
||
} else {
|
||
newDate.setDate(newDate.getDate() + 7);
|
||
}
|
||
|
||
const newScheduledDate = formatDateToISO(newDate);
|
||
|
||
// Preserve startTime — if the preferred slot is taken, find next free one
|
||
let resolvedStartTime = task.startTime || undefined;
|
||
if (resolvedStartTime) {
|
||
const targetSlotTasks = tasks.filter(t => {
|
||
if (t.id === taskId || !t.scheduledDate) return false;
|
||
const tDate = formatDateToISO(new Date(t.scheduledDate));
|
||
return tDate === newScheduledDate && t.startTime === resolvedStartTime;
|
||
});
|
||
if (targetSlotTasks.length > 0) {
|
||
// Slot is taken — find next free slot
|
||
const allSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd);
|
||
const startIndex = allSlots.indexOf(resolvedStartTime);
|
||
if (startIndex !== -1) {
|
||
for (let i = startIndex + 1; i < allSlots.length; i++) {
|
||
const candidate = allSlots[i];
|
||
const candidateTasks = tasks.filter(t => {
|
||
if (t.id === taskId || !t.scheduledDate) return false;
|
||
const tDate = formatDateToISO(new Date(t.scheduledDate));
|
||
return tDate === newScheduledDate && t.startTime === candidate;
|
||
});
|
||
if (candidateTasks.length === 0) {
|
||
resolvedStartTime = candidate;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, scheduledDate: newScheduledDate, dayOfWeek: newDate.getDay(), startTime: resolvedStartTime || t.startTime, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: taskId,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek: newDate.getDay(),
|
||
startTime: resolvedStartTime
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error rolling task:', error);
|
||
}
|
||
};
|
||
|
||
// Drag and drop handlers
|
||
const handleDragStart = (e: DragEvent, task: Task) => {
|
||
setDraggedTask(task);
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', task.id);
|
||
}
|
||
// Add drag-source class for styling
|
||
if (e.currentTarget instanceof HTMLElement) {
|
||
e.currentTarget.classList.add('drag-source');
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (e: DragEvent | React.DragEvent, dayOfWeek?: number, slot?: string) => {
|
||
e.preventDefault();
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.dropEffect = 'move';
|
||
}
|
||
// Update drop preview if we have day and slot info
|
||
if (dayOfWeek !== undefined && slot) {
|
||
setDropPreview({ day: dayOfWeek, slot });
|
||
}
|
||
};
|
||
|
||
const handleDrop = async (e: DragEvent, dayOfWeek: number, slot?: string) => {
|
||
e.preventDefault();
|
||
if (draggedTask) {
|
||
const visibleDays = getVisibleDays();
|
||
const targetDateObj = visibleDays.find(d => d.getDay() === dayOfWeek) || new Date();
|
||
|
||
let targetSlot = slot;
|
||
|
||
// If no slot provided (dropped on header/background), try to keep original time
|
||
if (!targetSlot && draggedTask.startTime) {
|
||
targetSlot = draggedTask.startTime;
|
||
}
|
||
|
||
// Collision detection / Find next free slot
|
||
if (targetSlot) {
|
||
const targetSlotTasks = getTasksForSlot(targetDateObj, targetSlot);
|
||
if (targetSlotTasks.length > 0 && !targetSlotTasks.some(t => t.id === draggedTask.id)) {
|
||
const allSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd);
|
||
const startIndex = allSlots.indexOf(targetSlot);
|
||
if (startIndex !== -1) {
|
||
for (let i = startIndex + 1; i < allSlots.length; i++) {
|
||
const nextSlot = allSlots[i];
|
||
const nextSlotTasks = getTasksForSlot(targetDateObj, nextSlot);
|
||
if (nextSlotTasks.length === 0) {
|
||
targetSlot = nextSlot;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If the task was from a someday list, move it to the calendar
|
||
if (draggedTask.somedayListId) {
|
||
const newScheduledDate = formatDateToISO(targetDateObj);
|
||
// Remove from someday list UI
|
||
setSomedayLists(prev => prev.map(l => ({
|
||
...l,
|
||
tasks: l.tasks.filter(t => t.id !== draggedTask.id)
|
||
})));
|
||
// Add to calendar tasks
|
||
setTasks(prev => [...prev, { ...draggedTask, somedayListId: null, scheduledDate: newScheduledDate, dayOfWeek, startTime: targetSlot || '' }]);
|
||
// Persist
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: draggedTask.id,
|
||
somedayListId: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || ''
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error moving task from someday to calendar:', error);
|
||
}
|
||
} else {
|
||
moveTaskToSlot(draggedTask.id, dayOfWeek, targetSlot || '', targetDateObj);
|
||
}
|
||
setDraggedTask(null);
|
||
}
|
||
setDropPreview(null);
|
||
};
|
||
|
||
const handleDragEnd = () => {
|
||
setDraggedTask(null);
|
||
setDropPreview(null);
|
||
// Remove drag-source class from all elements
|
||
document.querySelectorAll('.drag-source').forEach(el => el.classList.remove('drag-source'));
|
||
};
|
||
|
||
const handleDragLeave = () => {
|
||
setDropPreview(null);
|
||
};
|
||
|
||
// Sync calendar
|
||
const handleSync = async () => {
|
||
setSyncStatus('syncing');
|
||
try {
|
||
await fetchCalendarEvents();
|
||
setSyncStatus('synced');
|
||
// Reset status after 3 seconds
|
||
setTimeout(() => setSyncStatus('idle'), 3000);
|
||
} catch (error) {
|
||
console.error('Error syncing calendar:', error);
|
||
setSyncStatus('idle');
|
||
}
|
||
};
|
||
|
||
// Start adding someday list UI
|
||
const handleStartAddSomedayList = () => {
|
||
setIsAddingSomedayList(true);
|
||
// Focus will happen in render logic if possible or via ref, but let's render conditional input first
|
||
};
|
||
|
||
const saveSomedayList = async () => {
|
||
if (!newSomedayListName.trim()) {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName('');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/someday-lists', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: newSomedayListName.trim() }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setSomedayLists(prev => [...prev, {
|
||
...data.list,
|
||
tasks: [] // Initially empty
|
||
}]);
|
||
setNewSomedayListName('');
|
||
setIsAddingSomedayList(false);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding someday list:', error);
|
||
}
|
||
};
|
||
|
||
// Get time slots to display
|
||
// Get time slots to display
|
||
const visibleSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="weekly-container" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ color: 'var(--weekly-text-light)' }}>{translations[language]?.loading || translations['en'].loading}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`weekly-container ${darkMode ? 'dark-mode' : ''} font-size-${fontSize.toLowerCase()} ${viewStyle}-view`}>
|
||
{/* View Transitions Style Block */}
|
||
<style dangerouslySetInnerHTML={{
|
||
__html: (() => {
|
||
// Generate View Transition styles for a wide range of days around current view
|
||
// to ensure both entering and exiting days have the 500ms duration.
|
||
const center = currentWeekStart;
|
||
const validNames = [];
|
||
// Cover +/- 2 weeks just to be safe (exiting days need styles too)
|
||
for (let i = -14; i <= 21; i++) {
|
||
const d = new Date(center);
|
||
d.setDate(d.getDate() + i);
|
||
validNames.push(`day-${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`);
|
||
}
|
||
|
||
return validNames.map(name => `
|
||
::view-transition-group(${name}) {
|
||
animation-duration: 0.5s;
|
||
animation-timing-function: ease-in-out;
|
||
}
|
||
`).join('');
|
||
})()
|
||
}} />
|
||
|
||
{/* Refactored Header: Left, Center, Right */}
|
||
<header className="flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200">
|
||
{/* LEFT SECTION: Days to Show & Time Range */}
|
||
<div className="flex items-center gap-4">
|
||
{/* Days to Show */}
|
||
<div className="flex items-center gap-1 bg-gray-100 rounded p-1" title="Days to show">
|
||
<LayoutGrid size={16} className="text-gray-500 mr-1" />
|
||
{[1, 3, 5, 7].map(num => (
|
||
<button
|
||
key={num}
|
||
onClick={() => setViewDays(num)}
|
||
className={`px-2 py-0.5 text-xs rounded transition-colors ${viewDays === num ? 'bg-white shadow-sm font-bold text-black' : 'text-gray-500 hover:text-gray-900 hover:bg-gray-200'}`}
|
||
>
|
||
{num}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Time Range */}
|
||
{showTimeGrid && (
|
||
<div className="flex items-center gap-2 text-xs text-gray-500 bg-gray-100 rounded p-1 px-2" title="Visible Hours">
|
||
<Clock size={16} className="text-gray-500" />
|
||
<div className="flex items-center gap-1">
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
max={endHour - 1}
|
||
value={startHour}
|
||
onChange={(e) => setStartHour(Math.max(0, Math.min(parseInt(e.target.value) || 0, endHour - 1)))}
|
||
className="w-8 p-0.5 border border-gray-200 rounded text-center bg-transparent focus:outline-none focus:border-teal-500"
|
||
/>
|
||
<span>-</span>
|
||
<input
|
||
type="number"
|
||
min={startHour + 1}
|
||
max="24"
|
||
value={endHour}
|
||
onChange={(e) => setEndHour(Math.max(startHour + 1, Math.min(parseInt(e.target.value) || 24, 24)))}
|
||
className="w-8 p-0.5 border border-gray-200 rounded text-center bg-transparent focus:outline-none focus:border-teal-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Day/Night Mode Switch */}
|
||
<button
|
||
onClick={() => setDarkMode(!darkMode)}
|
||
className="p-1.5 rounded-md hover:bg-gray-100 transition-colors"
|
||
title={darkMode ? "Switch to Light Mode" : "Switch to Dark Mode"}
|
||
>
|
||
{darkMode ? <Sun size={18} className="text-yellow-500" /> : <Moon size={18} className="text-gray-500" />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* CENTER SECTION: Week/Year, Motto, Focus Mode */}
|
||
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2">
|
||
{/* Week & Year */}
|
||
<div className="text-lg font-bold whitespace-nowrap">
|
||
KW {getWeekNumber(currentWeekStart).toString().padStart(2, '0')} <span className="text-gray-400">|</span> {currentWeekStart.getFullYear()}
|
||
</div>
|
||
|
||
{/* Motto */}
|
||
<div className="flex items-center text-sm">
|
||
<span className="text-gray-300 mx-2">-</span>
|
||
{isEditingMotto ? (
|
||
<input
|
||
type="text"
|
||
value={motto}
|
||
onChange={(e) => setMotto(e.target.value)}
|
||
onBlur={() => setIsEditingMotto(false)}
|
||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
||
autoFocus
|
||
className="border-b border-gray-300 focus:outline-none focus:border-black px-1 text-center font-medium italic"
|
||
style={{ width: `${Math.max(10, motto.length)}ch` }}
|
||
/>
|
||
) : (
|
||
<span
|
||
onClick={() => !showNextTask && setIsEditingMotto(true)}
|
||
className={`cursor-pointer font-medium italic text-gray-600 hover:text-black transition-colors ${showNextTask ? 'cursor-default' : ''}`}
|
||
title={showNextTask ? "Next task" : "Edit motto"}
|
||
>
|
||
{showNextTask ? (() => {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const todayStr = formatDateToISO(today);
|
||
const todayDay = today.getDay();
|
||
const todaysTasks = tasks.filter(t => {
|
||
if (t.completed || t.somedayListId) return false;
|
||
if (t.scheduledDate) return formatDateToISO(new Date(t.scheduledDate)) === todayStr;
|
||
if (t.dayOfWeek === todayDay && !t.scheduledDate) return true;
|
||
return false;
|
||
}).sort((a, b) => {
|
||
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime);
|
||
if (a.startTime) return -1;
|
||
if (b.startTime) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
const nextTask = todaysTasks[0];
|
||
return nextTask ? `Do this now: ${nextTask.title}` : motto;
|
||
})() : motto}
|
||
</span>
|
||
)}
|
||
<span className="text-gray-300 mx-2">-</span>
|
||
</div>
|
||
|
||
{/* Focus Mode Toggle */}
|
||
<button
|
||
onClick={() => setShowFocusMode(true)}
|
||
className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-gray-100 hover:bg-gray-200 text-gray-600 transition-colors text-xs font-medium"
|
||
title="Enter Focus Mode"
|
||
>
|
||
<Target size={14} />
|
||
<span>Focus Mode</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* RIGHT SECTION: Navigation & Tools */}
|
||
<div className="flex items-center gap-3">
|
||
{/* Date Picker Toggle */}
|
||
<div className="relative">
|
||
<button
|
||
className={`p-1.5 hover:bg-gray-100 rounded-md transition-colors ${showDatePicker ? 'text-teal-600 bg-teal-50' : 'text-gray-500 hover:text-black'}`}
|
||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||
title="Jump to date"
|
||
>
|
||
<Calendar size={18} />
|
||
</button>
|
||
{showDatePicker && (
|
||
<SimpleDatePicker
|
||
selected={currentWeekStart}
|
||
onSelect={(date) => {
|
||
setCurrentWeekStart(getStartOfWeek(date));
|
||
setShowDatePicker(false);
|
||
}}
|
||
onClose={() => setShowDatePicker(false)}
|
||
language={language}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Navigation Controls */}
|
||
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
||
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToPrevWeek} title="Previous Week">
|
||
<ChevronsLeft size={16} />
|
||
</button>
|
||
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToPrevDay} title="Previous Day">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
<button className="px-3 py-1 text-xs font-bold text-gray-600 hover:text-black hover:bg-white hover:shadow-sm rounded transition-all" onClick={goToToday} title="Go to Today">
|
||
Today
|
||
</button>
|
||
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToNextDay} title="Next Day">
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToNextWeek} title="Next Week">
|
||
<ChevronsRight size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div className="h-4 w-px bg-gray-300 mx-1"></div>
|
||
|
||
{/* Search */}
|
||
<button
|
||
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
||
onClick={() => setIsSearchOpen(true)}
|
||
title="Search"
|
||
>
|
||
<Search size={18} />
|
||
</button>
|
||
|
||
{/* Settings - Actually triggers User Menu in original code? No, settings was separate. */}
|
||
{/* Original code had UserMenu handling settings. And a separate sync indicator. */}
|
||
{/* The prompt asked for: Search | Settings | User Menu */}
|
||
|
||
<button
|
||
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
||
onClick={() => setShowPreferences(true)}
|
||
title="Settings"
|
||
>
|
||
<Settings size={18} />
|
||
</button>
|
||
|
||
{/* User Menu */}
|
||
<UserMenu
|
||
userEmail={session?.user?.email}
|
||
onOpenRecurring={() => setIsRecurringTasksOpen(true)}
|
||
onOpenSettings={() => setShowPreferences(true)}
|
||
trigger={
|
||
<button className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors" title="User Menu">
|
||
<User size={18} />
|
||
</button>
|
||
}
|
||
/>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Main Grid with Time Column */}
|
||
<div className="time-grid-wrapper">
|
||
{/* Time Column */}
|
||
{showTimeGrid && (
|
||
<div className="time-column">
|
||
<div className="time-column-header" style={{ minHeight: `${getHeaderHeight(cellDuration)}px` }}></div>
|
||
<div
|
||
className="time-column-slots"
|
||
ref={timeColumnRef}
|
||
onScroll={handleTimeColumnScroll}
|
||
>
|
||
{visibleSlots.map((slot, index) => {
|
||
const hour = getHourFromSlot(slot);
|
||
const minutes = slot.split(':')[1];
|
||
const isHourStart = minutes === '00';
|
||
return (
|
||
<div
|
||
key={slot}
|
||
className={`time-slot-label ${isHourStart ? 'hour-start' : ''}`}
|
||
style={{ height: `${getSlotHeight(cellDuration)}px` }}
|
||
>
|
||
{isHourStart && <span>{formatHour(hour)}</span>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Day Columns */}
|
||
<main className={`weekly-days-grid cols-${viewDays} ${slideDirection ? `slide-${slideDirection}` : ''}`}>
|
||
{getVisibleDays().map((date, colIndex) => (
|
||
<div
|
||
key={date.toISOString()}
|
||
className="weekly-day-column"
|
||
style={{ viewTransitionName: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` } as any}
|
||
>
|
||
{/* Day Header */}
|
||
<header className="weekly-day-header">
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: '8px', rowGap: '0' }}>
|
||
<h3 className={`weekly-day-name ${isSameDay(date, new Date()) ? 'is-today' : ''}`} style={{ marginBottom: 0 }}>
|
||
{getDayName(date, language)}
|
||
</h3>
|
||
<div className="weekly-day-date" style={{ fontSize: '10px', fontWeight: 700, opacity: 0.6 }}>{formatDateHeader(date, language)}</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Time Grid or Simple List */}
|
||
{showTimeGrid ? (
|
||
<div
|
||
className="time-slots-container"
|
||
ref={el => { if (el) dayColumnsRef.current[colIndex] = el; }}
|
||
onScroll={(e) => handleDayColumnScroll(e, colIndex)}
|
||
onDragLeave={handleDragLeave}
|
||
style={{ position: 'relative' }}
|
||
>
|
||
{/* Now Line - only show on today's column */}
|
||
{isSameDay(date, new Date()) && (() => {
|
||
const now = currentTime;
|
||
const nowHour = now.getHours();
|
||
const nowMinute = now.getMinutes();
|
||
// Only show if within visible time range
|
||
if (nowHour >= workingHoursStart && nowHour < workingHoursEnd) {
|
||
const minutesSinceStart = (nowHour - workingHoursStart) * 60 + nowMinute;
|
||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||
const topPosition = minutesSinceStart * pixelsPerMinute;
|
||
return <div className="now-line" style={{ top: `${topPosition}px` }} />;
|
||
}
|
||
return null;
|
||
})()}
|
||
{/* Protection overlays - render at exact event positions */}
|
||
{protectEventTimes && getEventsForDate(date).filter(e => !isAllDayEvent(e)).map(event => {
|
||
const eventStart = new Date(event.startTime);
|
||
const eventEnd = new Date(event.endTime);
|
||
const eventStartHour = eventStart.getHours();
|
||
const eventStartMinute = eventStart.getMinutes();
|
||
|
||
|
||
// Only show if event is within visible time range
|
||
if (eventStartHour < workingHoursStart || eventStartHour >= workingHoursEnd) return null;
|
||
|
||
const minutesSinceStart = (eventStartHour - workingHoursStart) * 60 + eventStartMinute;
|
||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||
const topPosition = minutesSinceStart * pixelsPerMinute;
|
||
|
||
// Calculate height based on event duration
|
||
const durationMinutes = (eventEnd.getTime() - eventStart.getTime()) / (1000 * 60);
|
||
const height = durationMinutes * pixelsPerMinute;
|
||
|
||
const isUnlocked = unlockedEvents.has(event.id);
|
||
|
||
return (
|
||
<div
|
||
key={`protection-${event.id}`}
|
||
className="event-protection-overlay"
|
||
style={{
|
||
position: 'absolute',
|
||
top: `${topPosition}px`,
|
||
left: 0,
|
||
right: 0,
|
||
height: `${height}px`,
|
||
zIndex: 1,
|
||
pointerEvents: 'none'
|
||
}}
|
||
>
|
||
<button
|
||
className="event-unlock-btn"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setUnlockedEvents(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(event.id)) {
|
||
next.delete(event.id);
|
||
} else {
|
||
next.add(event.id);
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
title={isUnlocked ? 'Lock this time slot' : 'Unlock this time slot'}
|
||
style={{ pointerEvents: 'auto' }}
|
||
>
|
||
{isUnlocked ? '🔓' : '🔒'}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
{visibleSlots.map((slot) => {
|
||
const hour = getHourFromSlot(slot);
|
||
const minutes = slot.split(':')[1];
|
||
const isHourStart = minutes === '00';
|
||
const slotTasks = getTasksForSlot(date, slot);
|
||
const slotEvents = getEventsForSlot(date, slot);
|
||
const isActive = activeSlot?.day === date.getDay() && activeSlot?.slot === slot;
|
||
const isProtected = isSlotProtected(date, slot);
|
||
|
||
const handleSlotClick = (e: React.MouseEvent) => {
|
||
if (isProtected) return; // Don't allow adding tasks to protected slots
|
||
|
||
// Alt+Click to Create Calendar Event
|
||
if (e.altKey) {
|
||
e.stopPropagation();
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
initialDate: date,
|
||
initialStartTime: slot
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!isActive) {
|
||
setActiveSlot({ day: date.getDay(), slot });
|
||
setNewSlotTask('');
|
||
}
|
||
};
|
||
|
||
const handleSlotSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const taskTitle = newSlotTask.trim();
|
||
// Clear state immediately to prevent double submit
|
||
setActiveSlot(null);
|
||
setNewSlotTask('');
|
||
if (taskTitle) {
|
||
await addTask(date, taskTitle, slot);
|
||
}
|
||
};
|
||
|
||
const handleSlotDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
if (isProtected) return; // Don't allow dropping on protected slots
|
||
handleDrop(e, date.getDay(), slot);
|
||
};
|
||
|
||
const isDropTarget = dropPreview?.day === date.getDay() && dropPreview?.slot === slot;
|
||
|
||
return (
|
||
<div
|
||
key={slot}
|
||
className={`time-slot ${isHourStart ? 'hour-start' : ''} ${draggedTask && !isProtected ? 'drop-target' : ''} ${isActive ? 'active' : ''}`}
|
||
style={{ height: `${getSlotHeight(cellDuration)}px`, position: 'relative', cursor: isProtected ? 'not-allowed' : 'text' }}
|
||
onClick={handleSlotClick}
|
||
onDragOver={(e) => !isProtected && handleDragOver(e, date.getDay(), slot)}
|
||
onDrop={handleSlotDrop}
|
||
>
|
||
{/* Drop preview indicator */}
|
||
{isDropTarget && !isProtected && <div className="drop-preview" />}
|
||
{slotTasks.map(task => (
|
||
<div
|
||
key={task.id}
|
||
className={`time-slot-task ${task.completed ? 'completed' : ''} ${draggedTask?.id === task.id ? 'dragging' : ''}`}
|
||
draggable={!editingTaskId}
|
||
onDragStart={(e) => handleDragStart(e, task)}
|
||
onDragEnd={handleDragEnd}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (editingTaskId !== task.id) {
|
||
toggleTask(task.id);
|
||
}
|
||
}}
|
||
>
|
||
{editingTaskId === task.id ? (
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const input = e.currentTarget.elements.namedItem('title') as HTMLInputElement;
|
||
updateTask(task.id, input.value);
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{ width: '100%', paddingRight: '20px' }}
|
||
>
|
||
<input
|
||
name="title"
|
||
autoFocus
|
||
defaultValue={task.title}
|
||
onBlur={(e) => updateTask(task.id, e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') setEditingTaskId(null);
|
||
if (e.key === 'Enter') e.currentTarget.blur();
|
||
}}
|
||
className="weekly-task-text"
|
||
style={{ width: '100%', background: 'transparent', border: 'none', borderBottom: '1px solid #777', outline: 'none' }}
|
||
/>
|
||
{/* Duration Presets */}
|
||
<div className="duration-presets" style={{ display: 'flex', gap: '4px', marginTop: '6px', flexWrap: 'wrap' }}>
|
||
{[15, 30, 45, 60, 90, 120].map(m => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); updateTaskDuration(task.id, m); }}
|
||
title={`Set duration to ${m} minutes`}
|
||
style={{ fontSize: '0.7rem', padding: '2px 6px', background: '#f5f5f5', border: '1px solid #ccc', borderRadius: '3px', cursor: 'pointer', color: '#333' }}
|
||
>
|
||
{m < 60 ? `${m}m` : `${m / 60}h`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'pre-wrap', wordBreak: 'break-word', flex: 1 }} onDoubleClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}>
|
||
{task.title}
|
||
</span>
|
||
)}
|
||
<div className="task-actions" style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||
gap: '2px',
|
||
marginLeft: '4px',
|
||
flexShrink: 0
|
||
}}>
|
||
{/* Edit button */}
|
||
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }} title="Edit">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||
</button>
|
||
{/* Notes button */}
|
||
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); setSelectedTaskForNotes(task); }} title="Notes">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
||
</button>
|
||
{/* Roll button */}
|
||
{!task.completed && (
|
||
<button className={`task-action-btn ${task.isRolling ? 'active' : ''}`} onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }} title={task.isRolling ? "Disable rolling" : "Enable rolling"}>
|
||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
|
||
</button>
|
||
)}
|
||
{/* Recurrence button */}
|
||
<button
|
||
className={`task-action-btn ${task.isRecurring ? 'active' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }}
|
||
title={task.isRecurring ? "Edit recurrence" : "Make recurring"}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="23 4 23 10 17 10"></polyline>
|
||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||
</svg>
|
||
</button>
|
||
{/* Delete button */}
|
||
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{/* Calendar Events in time slot */}
|
||
{slotEvents.map(event => {
|
||
const eventHeight = getEventDuration(event);
|
||
const startTime = new Date(event.startTime);
|
||
const endTime = new Date(event.endTime);
|
||
const timeStr = `${startTime.getHours().toString().padStart(2, '0')}:${startTime.getMinutes().toString().padStart(2, '0')} - ${endTime.getHours().toString().padStart(2, '0')}:${endTime.getMinutes().toString().padStart(2, '0')}`;
|
||
|
||
// Calculate offset within the slot based on event start time
|
||
const [slotHour, slotMinute] = slot.split(':').map(Number);
|
||
const slotStartMinutes = slotHour * 60 + slotMinute;
|
||
const eventStartMinutes = startTime.getHours() * 60 + startTime.getMinutes();
|
||
const offsetMinutes = eventStartMinutes - slotStartMinutes;
|
||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||
const topOffset = offsetMinutes * pixelsPerMinute;
|
||
|
||
// Convert hex to rgba for background, or use default
|
||
const eventColor = event.calendarColor || '#009a9a';
|
||
const bgColor = eventColor.startsWith('#')
|
||
? `${eventColor}20` // Add alpha for transparency
|
||
: eventColor;
|
||
const borderColor = eventColor.startsWith('#')
|
||
? eventColor
|
||
: 'var(--weekly-teal)';
|
||
|
||
return (
|
||
<div
|
||
key={event.id}
|
||
className="time-slot-event"
|
||
title={`${event.calendarTitle}: ${event.title}\n${timeStr}`}
|
||
style={{
|
||
height: `${eventHeight}px`,
|
||
minHeight: `${eventHeight}px`,
|
||
position: 'absolute',
|
||
top: `${topOffset}px`,
|
||
left: '4px',
|
||
right: '4px',
|
||
zIndex: 5,
|
||
flexDirection: 'column',
|
||
alignItems: 'flex-start',
|
||
backgroundColor: bgColor,
|
||
borderLeftColor: borderColor,
|
||
color: borderColor,
|
||
cursor: event.editable ? 'pointer' : 'default'
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (event.editable) {
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: event
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
<div className="event-title-row">
|
||
<span className="event-indicator">📅</span>
|
||
<span className="event-title">{event.title}</span>
|
||
</div>
|
||
<div className="event-time-row">{timeStr}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{isActive && (
|
||
<form onSubmit={handleSlotSubmit} className="slot-input-form">
|
||
<input
|
||
type="text"
|
||
value={newSlotTask}
|
||
onChange={(e) => setNewSlotTask(e.target.value)}
|
||
onBlur={async () => {
|
||
// Only save if still active (not already submitted)
|
||
if (activeSlot && newSlotTask.trim()) {
|
||
const taskTitle = newSlotTask.trim();
|
||
setActiveSlot(null);
|
||
setNewSlotTask('');
|
||
await addTask(date, taskTitle, slot);
|
||
} else {
|
||
setActiveSlot(null);
|
||
setNewSlotTask('');
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') {
|
||
setActiveSlot(null);
|
||
setNewSlotTask('');
|
||
}
|
||
}}
|
||
autoFocus
|
||
className="slot-input"
|
||
/>
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{/* All Day Events Section */}
|
||
|
||
{/* Untimed Tasks List below grid */}
|
||
<div className="weekly-task-list" style={{ marginTop: '1rem', borderTop: '1px solid #eee', paddingTop: '0.5rem' }}>
|
||
{/* Filter for untimed tasks */}
|
||
{getTasksForDate(date)
|
||
.filter(task => !task.startTime)
|
||
.map(task => (
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(newTitle) => updateTask(task.id, newTitle)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Calendar Events */}
|
||
{getEventsForDate(date).map(event => {
|
||
const eventColor = event.calendarColor || '#009a9a';
|
||
const bgColor = eventColor.startsWith('#') ? `${eventColor}20` : eventColor;
|
||
const borderColor = eventColor.startsWith('#') ? eventColor : 'var(--weekly-teal)';
|
||
|
||
return (
|
||
<div key={event.id} className="weekly-calendar-event"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (event.editable) {
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: event
|
||
});
|
||
}
|
||
}}
|
||
style={{
|
||
backgroundColor: bgColor,
|
||
borderLeftColor: borderColor,
|
||
color: borderColor,
|
||
cursor: event.editable ? 'pointer' : 'default'
|
||
}}>
|
||
<div className="weekly-calendar-event-time" style={{ color: 'inherit', opacity: 0.8 }}>
|
||
{new Date(event.startTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||
</div>
|
||
<div className="weekly-calendar-event-title" style={{ color: 'inherit' }}>{event.title}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Tasks */}
|
||
<ol className="weekly-task-list">
|
||
{getTasksForDate(date).map(task => (
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(newTitle) => updateTask(task.id, newTitle)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
/>
|
||
))}
|
||
</ol>
|
||
</>
|
||
)
|
||
}
|
||
|
||
</div >
|
||
))}
|
||
</main >
|
||
</div >
|
||
|
||
{/* All-Day Events Section */}
|
||
{
|
||
(() => {
|
||
if (!showAllDay) return null;
|
||
const allDayEvents = calendarEvents.filter(event => isAllDayEvent(event));
|
||
if (allDayEvents.length === 0) return null;
|
||
|
||
return (
|
||
<section className={`all-day-events-section ${isAllDayExpanded ? 'expanded' : 'collapsed'}`}>
|
||
<div className="all-day-events-header" onClick={() => setIsAllDayExpanded(!isAllDayExpanded)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', position: 'relative', paddingRight: '2.5rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||
<span className="all-day-events-title">📆 {t.allDayEvents}</span>
|
||
<span className="all-day-events-count">{allDayEvents.length}</span>
|
||
</div>
|
||
<button className="all-day-chevron" style={{ position: 'absolute', top: '0.25rem', right: '1rem', background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.75rem', color: '#666', padding: '0.25rem' }}>
|
||
{isAllDayExpanded ? '▼' : '▶'}
|
||
</button>
|
||
</div>
|
||
{isAllDayExpanded && (
|
||
<div
|
||
className={`all-day-events-grid cols-${viewDays}`}
|
||
style={{ marginLeft: showTimeGrid ? '50px' : '0' }}
|
||
>
|
||
{getVisibleDays().map((date) => {
|
||
const dayEvents = getAllDayEventsForDate(date);
|
||
return (
|
||
<div key={date.toISOString()} className="all-day-events-column">
|
||
{dayEvents.length > 0 ? (
|
||
dayEvents.map(event => (
|
||
<div
|
||
key={event.id}
|
||
className="all-day-event"
|
||
title={`${event.calendarTitle}: ${event.title}`}
|
||
style={{
|
||
backgroundColor: event.calendarColor || '#3b82f6',
|
||
color: 'white',
|
||
borderLeft: 'none',
|
||
padding: '2px 4px',
|
||
borderRadius: '3px',
|
||
fontSize: '0.75rem',
|
||
marginBottom: '2px',
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px'
|
||
}}
|
||
>
|
||
<span className="event-indicator">📅</span>
|
||
<span className="all-day-event-title">{event.title}</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="all-day-empty"></div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
})()
|
||
}
|
||
|
||
{/* Someday Section */}
|
||
{
|
||
showSomeday && (
|
||
<section className={`weekly-someday ${somedayExpanded ? 'expanded' : 'collapsed'} dark:bg-gray-900 dark:text-white transition-colors duration-200`}>
|
||
<div className="weekly-someday-header" onClick={() => setSomedayExpanded(!somedayExpanded)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', position: 'relative', padding: '0.5rem 2.5rem' }}>
|
||
<button
|
||
className="someday-add-btn dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:border-white transition-colors"
|
||
onClick={(e) => { e.stopPropagation(); handleStartAddSomedayList(); }}
|
||
title="Add new list"
|
||
style={{ position: 'absolute', left: '1rem', top: '50%', transform: 'translateY(-50%)', background: 'none', border: '1px dashed #ccc', borderRadius: '50%', width: '24px', height: '24px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#888', fontSize: '1rem', lineHeight: 1, padding: 0 }}
|
||
>
|
||
+
|
||
</button>
|
||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||
<span className="weekly-someday-title dark:text-white" style={{ fontWeight: 'bold' }}>{translations[language]?.someday || translations['en'].someday}</span>
|
||
<span className="weekly-someday-count dark:text-gray-400" style={{ marginLeft: '12px', fontSize: '0.9rem', color: '#888' }}>
|
||
{somedayLists.length} {translations[language]?.lists || translations['en'].lists}
|
||
</span>
|
||
</div>
|
||
<button
|
||
className="someday-chevron dark:text-gray-400"
|
||
style={{ position: 'absolute', right: '1rem', top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.75rem', color: '#666', padding: '0.25rem' }}
|
||
>
|
||
{somedayExpanded ? '▼' : '▶'}
|
||
</button>
|
||
</div>
|
||
|
||
{somedayExpanded && (
|
||
<div className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}>
|
||
{(somedayLists.length > 0 ? somedayLists : [{ id: 'default', title: 'LISTE', tasks: [] }]).slice(0, Math.max(somedayLists.length, viewDays)).map(list => (
|
||
<div
|
||
key={list.id}
|
||
className={`weekly-someday-list ${draggingListId === list.id ? 'is-dragging' : ''} dark:bg-gray-800 dark:border-gray-700 rounded-lg p-2 transition-colors duration-200`}
|
||
style={{
|
||
minHeight: '200px',
|
||
cursor: 'text', // Indicate actionable area
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
}}
|
||
onClick={(e) => {
|
||
// Focus the add input if clicking empty area or the list container
|
||
// Only if not clicking a task item or other interactive element
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest('.weekly-task-item') || target.tagName === 'INPUT' || target.tagName === 'BUTTON') {
|
||
return;
|
||
}
|
||
|
||
const input = e.currentTarget.querySelector(`[data-someday-add-input="${list.id}"]`) as HTMLInputElement;
|
||
if (input) {
|
||
input.focus();
|
||
}
|
||
}}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
// Only drag if clicking the header
|
||
const target = e.target as HTMLElement;
|
||
if (!target.closest('.weekly-someday-list-title-header')) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
setDraggingListId(list.id);
|
||
e.dataTransfer.setData('text/list-id', list.id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
}}
|
||
onDragEnd={() => setDraggingListId(null)}
|
||
onDragOver={(e) => {
|
||
e.preventDefault(); // Allow drop
|
||
e.dataTransfer.dropEffect = 'move';
|
||
}}
|
||
onDrop={async (e) => {
|
||
e.preventDefault();
|
||
setDraggingListId(null);
|
||
const draggedListId = e.dataTransfer.getData('text/list-id');
|
||
const draggedTaskId = e.dataTransfer.getData('text/plain');
|
||
|
||
if (draggedListId === list.id) return;
|
||
|
||
// Check if a calendar task is being dropped into this someday list
|
||
if (draggedTaskId && draggedTask && draggedTask.id === draggedTaskId) {
|
||
// Move calendar task to this someday list
|
||
const taskToMove = draggedTask;
|
||
// Remove from calendar tasks
|
||
setTasks(prev => prev.filter(t => t.id !== taskToMove.id));
|
||
|
||
// Add to target someday list and remove from source/other someday lists
|
||
const movedTask = { ...taskToMove, somedayListId: list.id, scheduledDate: undefined, dayOfWeek: null, startTime: '' };
|
||
setSomedayLists(prev => prev.map(l => {
|
||
// Filter out the task from all lists first (handles source removal and prevents target duplicates)
|
||
const filteredTasks = l.tasks.filter(t => t.id !== taskToMove.id);
|
||
if (l.id === list.id) {
|
||
return { ...l, tasks: [...filteredTasks, movedTask] };
|
||
}
|
||
return { ...l, tasks: filteredTasks };
|
||
}));
|
||
setDraggedTask(null);
|
||
// Persist
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: taskToMove.id,
|
||
somedayListId: list.id,
|
||
scheduledDate: null,
|
||
dayOfWeek: null,
|
||
startTime: null
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error moving task to someday list:', error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Reorder logic (list drag)
|
||
if (!draggedListId) return;
|
||
const draggedIndex = somedayLists.findIndex(l => l.id === draggedListId);
|
||
const targetIndex = somedayLists.findIndex(l => l.id === list.id);
|
||
|
||
if (draggedIndex === -1 || targetIndex === -1) return;
|
||
|
||
const newLists = [...somedayLists];
|
||
const [draggedItem] = newLists.splice(draggedIndex, 1);
|
||
newLists.splice(targetIndex, 0, draggedItem);
|
||
|
||
setSomedayLists(newLists);
|
||
|
||
// Persist order
|
||
const orderUpdates = newLists.map((l, index) => ({ id: l.id, order: index }));
|
||
try {
|
||
await fetch('/api/someday-lists', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(orderUpdates)
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to update list order', err);
|
||
}
|
||
}}
|
||
>
|
||
<div className="weekly-someday-list-title-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'grab' }} title="Drag to reorder">
|
||
{/* Editable Title */}
|
||
<input
|
||
type="text"
|
||
defaultValue={list.title}
|
||
className="weekly-someday-list-title-input dark:bg-transparent dark:text-white"
|
||
onBlur={async (e) => {
|
||
const newTitle = e.target.value.trim();
|
||
if (newTitle && newTitle !== list.title) {
|
||
try {
|
||
await fetch('/api/someday-lists', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: list.id, title: newTitle }),
|
||
});
|
||
setSomedayLists(prev => prev.map(l => l.id === list.id ? { ...l, title: newTitle } : l));
|
||
} catch (err) {
|
||
console.error(err);
|
||
e.target.value = list.title;
|
||
}
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') e.currentTarget.blur();
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={async (e) => {
|
||
e.stopPropagation();
|
||
if (confirm('Delete this list?')) {
|
||
try {
|
||
await fetch(`/api/someday-lists?id=${list.id}`, { method: 'DELETE' });
|
||
setSomedayLists(prev => prev.filter(l => l.id !== list.id));
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
}}
|
||
style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: '1rem', color: '#ccc', marginLeft: 'auto' }}
|
||
title="Delete List"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<ol className="weekly-task-list" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||
{list.tasks.map(task => (
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(title) => updateTask(task.id, title)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={() => setSelectedTaskForNotes(task)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
variant="minimal"
|
||
isSomeday={true}
|
||
/>
|
||
))}
|
||
<SomedayAddTask listId={list.id} onAdd={async (title) => {
|
||
try {
|
||
const res = await fetch('/api/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title, somedayListId: list.id }),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const newTask = { ...data.task, createdAt: new Date(data.task.createdAt), updatedAt: new Date(data.task.updatedAt) };
|
||
setSomedayLists(prev => prev.map(l =>
|
||
l.id === list.id ? { ...l, tasks: [...l.tasks, newTask] } : l
|
||
));
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}} />
|
||
{Array.from({ length: Math.max(0, 5 - list.tasks.length) }).map((_, i) => (
|
||
<li key={`filler-${i}`} className="weekly-task-item minimal filler" style={{
|
||
borderBottom: '1px dashed #eee',
|
||
height: '32px',
|
||
margin: '0 0.5rem',
|
||
pointerEvents: 'none'
|
||
}}></li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
))}
|
||
|
||
{/* Modal for adding lists if needed, or just rely on placeholders */}
|
||
{isAddingSomedayList && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={(e) => { e.stopPropagation(); setIsAddingSomedayList(false); }}>
|
||
<div className="bg-white p-6 rounded-lg shadow-lg w-96 max-w-full m-4" onClick={e => e.stopPropagation()}>
|
||
<h3 className="text-lg font-bold mb-4">New List</h3>
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
placeholder="LIST NAME..."
|
||
value={newSomedayListName}
|
||
onChange={(e) => setNewSomedayListName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') saveSomedayList();
|
||
if (e.key === 'Escape') {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName('');
|
||
}
|
||
}}
|
||
className="w-full p-2 border border-gray-300 rounded mb-4"
|
||
/>
|
||
<div className="flex justify-end gap-2">
|
||
<button onClick={() => setIsAddingSomedayList(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded">Cancel</button>
|
||
<button onClick={saveSomedayList} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Create</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
{/* Search Modal */}
|
||
<SearchModal
|
||
isOpen={isSearchOpen}
|
||
onClose={() => setIsSearchOpen(false)}
|
||
tasks={tasks}
|
||
events={calendarEvents}
|
||
onSelectTask={(date) => {
|
||
setCurrentWeekStart(getStartOfWeek(date));
|
||
}}
|
||
/>
|
||
|
||
{/* Recurring Tasks Manager */}
|
||
<RecurringTasksManager
|
||
isOpen={isRecurringTasksOpen}
|
||
onClose={() => setIsRecurringTasksOpen(false)}
|
||
tasks={tasks} // Pass tasks to manager
|
||
/>
|
||
{/* Recurrence Modal */}
|
||
{
|
||
selectedTaskForRecurrence && (
|
||
<RecurrenceModal
|
||
task={selectedTaskForRecurrence}
|
||
onClose={() => setSelectedTaskForRecurrence(null)}
|
||
onSave={async (recurrence) => {
|
||
await updateTaskFields(selectedTaskForRecurrence.id, recurrence);
|
||
setSelectedTaskForRecurrence(null);
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{/* Calendar Event Modal */}
|
||
{
|
||
calendarEventModal.isOpen && (
|
||
<CalendarEventModal
|
||
event={calendarEventModal.event}
|
||
initialDate={calendarEventModal.initialDate}
|
||
initialStartTime={calendarEventModal.initialStartTime}
|
||
connections={connections}
|
||
onClose={() => setCalendarEventModal({ ...calendarEventModal, isOpen: false })}
|
||
onSave={handleEventSave}
|
||
onDelete={handleEventDelete}
|
||
/>
|
||
)
|
||
}
|
||
{/* Focus Mode Overlay */}
|
||
{
|
||
showFocusMode && (
|
||
<FocusModeOverlay
|
||
task={(() => {
|
||
// Logic to find the "Next Task"
|
||
// 1. Tasks for today with start time, sorted by time
|
||
// 2. Tasks for today without start time, sorted by order
|
||
// 3. Tasks rolling over from previous days
|
||
|
||
const now = new Date();
|
||
const todayStr = now.toISOString().split('T')[0];
|
||
|
||
// Get all tasks relevant for "Now"
|
||
const activeTasks = tasks.filter(t =>
|
||
!t.completed &&
|
||
!t.somedayListId &&
|
||
(
|
||
// Scheduled for today
|
||
(t.scheduledDate && new Date(t.scheduledDate).toISOString().split('T')[0] === todayStr) ||
|
||
// Or rolling and overdue (simplified, assuming rolling means show on today if not done)
|
||
(t.isRolling && (!t.scheduledDate || new Date(t.scheduledDate) <= now)) ||
|
||
// Or implicitly today if within current view logic (e.g. dayOfWeek match in current week)
|
||
// But let's stick to explicit date or rolling for Focus Mode to be precise.
|
||
(!t.scheduledDate && t.dayOfWeek === now.getDay() && isSameDay(currentWeekStart, getStartOfWeek(now)))
|
||
)
|
||
);
|
||
|
||
// Sort: Time-based first, then Order
|
||
activeTasks.sort((a, b) => {
|
||
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime);
|
||
if (a.startTime) return -1;
|
||
if (b.startTime) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
|
||
return activeTasks.length > 0 ? activeTasks[0] : null;
|
||
})()}
|
||
duration={focusTimerDuration}
|
||
onClose={() => setShowFocusMode(false)}
|
||
onComplete={(taskId) => toggleTask(taskId)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{/* Settings Modal */}
|
||
{
|
||
showSettings && (
|
||
<SettingsModal
|
||
onClose={() => setShowSettings(false)}
|
||
onSettingsChanged={handleSettingsChanged}
|
||
showTimeGrid={showTimeGrid}
|
||
setShowTimeGrid={setShowTimeGrid}
|
||
cellDuration={cellDuration}
|
||
setCellDuration={setCellDuration}
|
||
viewStyle={viewStyle}
|
||
setViewStyle={setViewStyle}
|
||
showSomeday={showSomeday}
|
||
setShowSomeday={setShowSomeday}
|
||
showAllDay={showAllDay}
|
||
setShowAllDay={setShowAllDay}
|
||
motto={motto}
|
||
setMotto={setMotto}
|
||
connections={connections}
|
||
onUpdateConnections={setConnections}
|
||
focusTimerDuration={focusTimerDuration}
|
||
setFocusTimerDuration={setFocusTimerDuration}
|
||
fontSize={fontSize}
|
||
setFontSize={setFontSize}
|
||
showNextTask={showNextTask}
|
||
setShowNextTask={setShowNextTask}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{
|
||
selectedTaskForRecurrence && (
|
||
<TaskRecurrenceModal
|
||
task={selectedTaskForRecurrence}
|
||
onClose={() => setSelectedTaskForRecurrence(null)}
|
||
onSave={handleRecurrenceSave}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{/* Preferences Slide-in Panel */}
|
||
<div className={`preferences-panel ${showPreferences ? 'open' : ''}`}>
|
||
<div className="preferences-panel-content">
|
||
<h3 style={{ fontSize: '1.2rem', fontWeight: 400, marginBottom: '1.5rem', color: '#fff' }}>Preferences</h3>
|
||
|
||
{/* Columns (View Days) */}
|
||
<div className="pref-row">
|
||
<span className="pref-label">Columns</span>
|
||
<div className="pref-options">
|
||
{[1, 3, 5, 7].map(n => (
|
||
<button key={n} className={`pref-option-btn ${viewDays === n ? 'active' : ''}`}
|
||
onClick={() => setViewDays(n as any)}>{n}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Show Time Grid */}
|
||
<div className="pref-row">
|
||
<span className="pref-label">Time grid</span>
|
||
<div className="pref-options">
|
||
<button className={`pref-toggle ${showTimeGrid ? 'active' : ''}`}
|
||
onClick={() => setShowTimeGrid(!showTimeGrid)}>
|
||
{showTimeGrid ? '◉' : '○'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{showTimeGrid && (
|
||
<div className="pref-row">
|
||
<span className="pref-label">Slot duration</span>
|
||
<div className="pref-options">
|
||
{[15, 30, 60].map(n => (
|
||
<button key={n} className={`pref-option-btn ${cellDuration === n ? 'active' : ''}`}
|
||
onClick={() => setCellDuration(n as CellDuration)}>{n}m</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* View Style */}
|
||
<div className="pref-row">
|
||
<span className="pref-label">View style</span>
|
||
<div className="pref-options">
|
||
<button className={`pref-option-btn ${viewStyle === 'grid' ? 'active' : ''}`}
|
||
onClick={() => setViewStyle('grid')}>Grid</button>
|
||
<button className={`pref-option-btn ${viewStyle === 'list' ? 'active' : ''}`}
|
||
onClick={() => setViewStyle('list')}>List</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Show Someday */}
|
||
<div className="pref-row">
|
||
<span className="pref-label">Someday</span>
|
||
<div className="pref-options">
|
||
<button className={`pref-toggle ${showSomeday ? 'active' : ''}`}
|
||
onClick={() => setShowSomeday(!showSomeday)}>
|
||
{showSomeday ? '◉' : '○'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Show All Day */}
|
||
<div className="pref-row">
|
||
<span className="pref-label">All-day events</span>
|
||
<div className="pref-options">
|
||
<button className={`pref-toggle ${showAllDay ? 'active' : ''}`}
|
||
onClick={() => setShowAllDay(!showAllDay)}>
|
||
{showAllDay ? '◉' : '○'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Lines */}
|
||
<div className="pref-row">
|
||
<span className="pref-label">Protect events</span>
|
||
<div className="pref-options">
|
||
<button className={`pref-toggle ${protectEventTimes ? 'active' : ''}`}
|
||
onClick={() => setProtectEventTimes(!protectEventTimes)}>
|
||
{protectEventTimes ? '◉' : '○'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '1rem 0' }} />
|
||
|
||
{/* Full Settings Link */}
|
||
<button
|
||
className="pref-full-settings-btn"
|
||
onClick={() => { setShowPreferences(false); setShowSettings(true); }}
|
||
>
|
||
⚙ Full Settings
|
||
</button>
|
||
</div>
|
||
|
||
{/* Close chevron */}
|
||
<button className="preferences-close-btn" onClick={() => setShowPreferences(false)}>
|
||
‹‹
|
||
</button>
|
||
</div>
|
||
{showPreferences && <div className="preferences-overlay" onClick={() => setShowPreferences(false)} />}
|
||
|
||
{
|
||
selectedTaskForNotes && (
|
||
<div className="weekly-modal-overlay" onClick={() => setSelectedTaskForNotes(null)}>
|
||
<div className="weekly-modal-content" onClick={e => e.stopPropagation()}>
|
||
<h3>Notes: {selectedTaskForNotes.title}</h3>
|
||
{/* Toolbar for Modal */}
|
||
<div className="notes-toolbar" style={{ marginTop: '1rem' }}>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}**${selection}**${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
// Hacky re-focus and update value visually since it's uncontrolled-ish/onBlur driven
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 2, start + 2 + selection.length);
|
||
}} title="Bold">B</button>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}*${selection}*${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 1, start + 1 + selection.length);
|
||
}} title="Italic">i</button>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}[${selection}](url)${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 1, start + 1 + selection.length);
|
||
}} title="Link">🔗</button>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}- ${selection}${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 2, start + 2 + selection.length);
|
||
}} title="List">☑</button>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 2, start + 10); // select "alt text"
|
||
}} title="Image">🖼️</button>
|
||
</div>
|
||
|
||
<textarea
|
||
className="weekly-notes-editor"
|
||
defaultValue={selectedTaskForNotes.markdownContent || ''}
|
||
autoFocus
|
||
placeholder="Add details, notes, or links..."
|
||
onBlur={(e) => updateTaskNotes(selectedTaskForNotes.id, e.target.value)}
|
||
/>
|
||
<div className="weekly-modal-actions">
|
||
<button className="weekly-btn weekly-btn-secondary" onClick={() => setSelectedTaskForNotes(null)}>Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
</div >
|
||
);
|
||
}
|
||
|
||
// Task Input Component
|
||
interface TaskInputProps {
|
||
onAddTask: (title: string) => void;
|
||
onDragOver: (e: React.DragEvent) => void;
|
||
onDrop: (e: React.DragEvent) => void;
|
||
}
|
||
|
||
function TaskInput({ onAddTask, onDragOver, onDrop }: TaskInputProps) {
|
||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||
|
||
const handleAddTask = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (newTaskTitle.trim()) {
|
||
onAddTask(newTaskTitle);
|
||
setNewTaskTitle('');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<form
|
||
onSubmit={handleAddTask}
|
||
className="weekly-task-input"
|
||
onDragOver={onDragOver}
|
||
onDrop={onDrop}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={newTaskTitle}
|
||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||
placeholder="Type a to-do..."
|
||
/>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function SomedayAddTask({ listId, onAdd }: { listId: string; onAdd: (title: string) => void }) {
|
||
const [title, setTitle] = useState('');
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleSubmit = () => {
|
||
if (title.trim()) {
|
||
onAdd(title.trim());
|
||
setTitle('');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<li className="weekly-task-item minimal" style={{ borderBottom: '1px dashed #eee', margin: '0 0.5rem' }}>
|
||
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }} style={{ width: '100%' }}>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
onBlur={handleSubmit}
|
||
onKeyDown={(e) => { if (e.key === 'Escape') { setTitle(''); e.currentTarget.blur(); } }}
|
||
className="weekly-task-text"
|
||
style={{
|
||
width: '100%', border: 'none',
|
||
background: 'transparent', padding: '0 0', fontSize: '0.8rem', outline: 'none',
|
||
height: '24px', display: 'block' // Height match filler
|
||
}}
|
||
placeholder=""
|
||
data-someday-add-input={listId}
|
||
/>
|
||
</form>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
// Task Item Component
|
||
interface TaskItemProps {
|
||
task: Task;
|
||
isEditing: boolean;
|
||
onToggle: () => void;
|
||
onEdit: () => void;
|
||
onUpdate: (title: string) => void;
|
||
onDelete: () => void;
|
||
onNotes: (notes: string) => void;
|
||
onRollToggle: () => void;
|
||
onRecurrence: () => void;
|
||
onDragStart: (e: DragEvent, task: Task) => void;
|
||
onDragEnd: () => void;
|
||
variant?: 'default' | 'minimal';
|
||
isSomeday?: boolean;
|
||
}
|
||
|
||
function TaskItem({ task, isEditing, onToggle, onEdit, onUpdate, onDelete, onNotes, onRollToggle, onRecurrence, onDragStart, onDragEnd, variant = 'default', isSomeday = false }: TaskItemProps) {
|
||
const [editValue, setEditValue] = useState(task.title);
|
||
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
||
const [notesValue, setNotesValue] = useState(task.markdownContent || '');
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (isEditing && inputRef.current) {
|
||
inputRef.current.focus();
|
||
inputRef.current.select();
|
||
}
|
||
}, [isEditing]);
|
||
|
||
// Focus notes when opened
|
||
useEffect(() => {
|
||
if (isNotesOpen && notesRef.current) {
|
||
notesRef.current.focus();
|
||
}
|
||
}, [isNotesOpen]);
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
onUpdate(editValue);
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
setEditValue(task.title);
|
||
onUpdate(task.title);
|
||
}
|
||
};
|
||
|
||
const handleNotesBlur = () => {
|
||
if (notesValue !== task.markdownContent) {
|
||
onNotes(notesValue);
|
||
}
|
||
};
|
||
|
||
// Markdown insertion helper
|
||
const insertMarkdown = (prefix: string, suffix: string = '') => {
|
||
if (!notesRef.current) return;
|
||
|
||
const start = notesRef.current.selectionStart;
|
||
const end = notesRef.current.selectionEnd;
|
||
const text = notesValue;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
|
||
const newText = `${before}${prefix}${selection}${suffix}${after}`;
|
||
setNotesValue(newText);
|
||
|
||
setTimeout(() => {
|
||
if (notesRef.current) {
|
||
notesRef.current.focus();
|
||
const newCursorPos = start + prefix.length + selection.length + suffix.length;
|
||
notesRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||
}
|
||
}, 0);
|
||
};
|
||
|
||
return (
|
||
<li
|
||
className={`weekly-task-item ${variant} ${task.completed ? 'completed' : ''} ${isSomeday ? 'relative mx-2' : ''}`}
|
||
draggable={!isEditing && !isNotesOpen} // Disable drag when editing
|
||
onDragStart={(e) => onDragStart(e as unknown as DragEvent, task)}
|
||
onDragEnd={onDragEnd}
|
||
onClick={(e) => {
|
||
if ((variant === 'minimal' || isSomeday) && !isEditing) {
|
||
const target = e.target as HTMLElement;
|
||
if (target.tagName === 'BUTTON' || target.tagName === 'INPUT' || target.closest('button')) return;
|
||
onEdit();
|
||
}
|
||
}}
|
||
>
|
||
<div style={{ width: '100%', position: 'relative' }}>
|
||
{/* Visual Indicator for Rolling Tasks */}
|
||
{task.isRolling && !task.completed && (
|
||
<div className="rolling-icon-indicator" title="Auto-rolling task">
|
||
<svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="23 4 23 10 17 10"></polyline>
|
||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||
</svg>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', width: '100%' }}>
|
||
{isEditing ? (
|
||
<form onSubmit={handleSubmit} style={{ flex: 1, display: 'flex' }}>
|
||
{variant === 'minimal' ? (
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
className="weekly-task-text"
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={() => onUpdate(editValue)}
|
||
style={{
|
||
border: 'none', background: 'transparent',
|
||
outline: 'none', width: '100%', padding: '0',
|
||
fontSize: '0.9375rem', fontWeight: 500
|
||
}}
|
||
/>
|
||
) : (
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
className="weekly-task-text"
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={() => onUpdate(editValue)}
|
||
/>
|
||
)}
|
||
</form>
|
||
) : (
|
||
<>
|
||
<span
|
||
className={`weekly-task-text ${task.completed ? 'completed' : ''}`}
|
||
onClick={(e) => {
|
||
if (variant === 'default') onToggle();
|
||
// For minimal, parent onClick handles edit
|
||
}}
|
||
onDoubleClick={variant === 'default' ? onEdit : undefined}
|
||
style={variant === 'minimal' ? { fontSize: '0.9375rem' } : undefined}
|
||
>
|
||
{task.title}
|
||
</span>
|
||
|
||
<div className={variant === 'minimal'
|
||
? "task-actions absolute right-0 top-0 bottom-0 bg-white/90 pl-1"
|
||
: "task-actions"
|
||
}
|
||
style={variant === 'minimal' ? { display: 'flex', alignItems: 'center' } : undefined}
|
||
>
|
||
{/* Edit */}
|
||
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); onEdit(); }} title="Edit">
|
||
<svg viewBox="0 0 24 24" width={variant === 'minimal' ? "14" : "16"} height={variant === 'minimal' ? "14" : "16"} stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||
</button>
|
||
|
||
{/* Recurrence */}
|
||
<button
|
||
className={`task-action-btn ${task.isRecurring ? 'active' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); onRecurrence(); }}
|
||
title={task.isRecurring ? "Edit recurrence" : "Make recurring"}
|
||
>
|
||
<svg viewBox="0 0 24 24" width={variant === 'minimal' ? "14" : "16"} height={variant === 'minimal' ? "14" : "16"} stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="23 4 23 10 17 10"></polyline>
|
||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Notes */}
|
||
<button
|
||
className={`task-action-btn ${isNotesOpen || (task.markdownContent && task.markdownContent.trim().length > 0) ? 'active' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }}
|
||
title="Notes"
|
||
>
|
||
<svg viewBox="0 0 24 24" width={variant === 'minimal' ? "14" : "16"} height={variant === 'minimal' ? "14" : "16"} stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Roll Toggle - Active State Colored (hidden for someday tasks) */}
|
||
{!task.completed && !isSomeday && (
|
||
<button
|
||
className={`task-action-btn ${task.isRolling ? 'active' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); onRollToggle(); }}
|
||
title={task.isRolling ? "Disable rolling" : "Enable rolling"}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="1 4 1 10 7 10"></polyline>
|
||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* Delete */}
|
||
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); onDelete(); }} title="Delete">
|
||
<svg viewBox="0 0 24 24" width={variant === 'minimal' ? "14" : "16"} height={variant === 'minimal' ? "14" : "16"} stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Inline Notes Editor with Toolbar */}
|
||
{isNotesOpen && (
|
||
<div className="weekly-notes-inline" onClick={(e) => e.stopPropagation()}>
|
||
<div className="notes-toolbar">
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('**', '**')} title="Bold">B</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('*', '*')} title="Italic">i</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('[', '](url)')} title="Link">🔗</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('- ')} title="List">☑</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('')} title="Image">🖼️</button>
|
||
<span style={{ marginLeft: 'auto', fontSize: '0.75rem', color: '#999' }}>Markdown supported</span>
|
||
</div>
|
||
<textarea
|
||
ref={notesRef}
|
||
className="weekly-notes-editor-inline"
|
||
value={notesValue}
|
||
onChange={(e) => setNotesValue(e.target.value)}
|
||
onBlur={handleNotesBlur}
|
||
placeholder="Add notes..."
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
// Recurrence Modal Component
|
||
interface RecurrenceModalProps {
|
||
task: Task;
|
||
onClose: () => void;
|
||
onSave: (recurrence: { isRecurring: boolean, interval: number, unit: string, time: string, endDate: Date | null }) => void;
|
||
}
|
||
|
||
function RecurrenceModal({ task, onClose, onSave }: RecurrenceModalProps) {
|
||
const [isRecurring, setIsRecurring] = useState(task.isRecurring || false);
|
||
const [interval, setInterval] = useState(task.recurrenceInterval || 1);
|
||
const [unit, setUnit] = useState(task.recurrenceUnit || 'weeks');
|
||
const [time, setTime] = useState(task.recurrenceTime || task.startTime || '09:00');
|
||
const [endDate, setEndDate] = useState<string>(task.recurrenceEndDate ? formatDateToISO(new Date(task.recurrenceEndDate)) : '');
|
||
|
||
const handleSave = () => {
|
||
onSave({
|
||
isRecurring,
|
||
interval,
|
||
unit,
|
||
time,
|
||
endDate: endDate ? new Date(endDate) : null
|
||
});
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
<div className="weekly-modal-overlay" onClick={onClose}>
|
||
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
|
||
<h3 style={{ marginBottom: '1.5rem' }}>Recurring Task</h3>
|
||
|
||
<div style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={isRecurring}
|
||
onChange={(e) => setIsRecurring(e.target.checked)}
|
||
style={{ width: 'auto', marginRight: '4px' }}
|
||
/>
|
||
Enable Recurrence
|
||
</label>
|
||
</div>
|
||
|
||
{isRecurring && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontSize: '0.9rem', color: '#666' }}>Repeat every</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={interval}
|
||
onChange={(e) => setInterval(parseInt(e.target.value) || 1)}
|
||
style={{ width: '60px', padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
<select
|
||
value={unit}
|
||
onChange={(e) => setUnit(e.target.value)}
|
||
style={{ padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="days">Days</option>
|
||
<option value="weeks">Weeks</option>
|
||
<option value="months">Months</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontSize: '0.9rem', color: '#666' }}>At time</span>
|
||
<input
|
||
type="time"
|
||
value={time}
|
||
onChange={(e) => setTime(e.target.value)}
|
||
style={{ padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontSize: '0.9rem', color: '#666' }}>End date (optional)</span>
|
||
<input
|
||
type="date"
|
||
value={endDate}
|
||
onChange={(e) => setEndDate(e.target.value)}
|
||
style={{ padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="weekly-modal-actions" style={{ marginTop: '1.5rem' }}>
|
||
<button className="weekly-btn weekly-btn-secondary" onClick={onClose}>Cancel</button>
|
||
<button className="weekly-btn weekly-btn-primary" onClick={handleSave}>Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Settings Modal Component
|
||
interface SettingsModalProps {
|
||
onClose: () => void;
|
||
onSettingsChanged?: (newSettings: {
|
||
showTimeGrid: boolean;
|
||
cellDuration: CellDuration;
|
||
viewStyle: 'grid' | 'list';
|
||
language: string;
|
||
dateFormat: string;
|
||
timeFormat: string;
|
||
startHour: number;
|
||
endHour: number;
|
||
fontSize: 'S' | 'M' | 'L';
|
||
showNextTask: boolean;
|
||
}) => void;
|
||
showTimeGrid: boolean;
|
||
setShowTimeGrid: (show: boolean) => void;
|
||
cellDuration: CellDuration;
|
||
setCellDuration: (duration: CellDuration) => void;
|
||
viewStyle: 'grid' | 'list';
|
||
setViewStyle: (style: 'grid' | 'list') => void;
|
||
showSomeday: boolean;
|
||
setShowSomeday: (show: boolean) => void;
|
||
showAllDay: boolean;
|
||
setShowAllDay: (show: boolean) => void;
|
||
motto: string;
|
||
setMotto: (motto: string) => void;
|
||
connections: any[];
|
||
onUpdateConnections: (connections: any[]) => void;
|
||
focusTimerDuration: number;
|
||
setFocusTimerDuration: (duration: number) => void;
|
||
fontSize: 'S' | 'M' | 'L';
|
||
setFontSize: (size: 'S' | 'M' | 'L') => void;
|
||
showNextTask: boolean;
|
||
setShowNextTask: (show: boolean) => void;
|
||
}
|
||
|
||
function SettingsModal({
|
||
onClose,
|
||
onSettingsChanged,
|
||
showTimeGrid,
|
||
setShowTimeGrid,
|
||
cellDuration,
|
||
setCellDuration,
|
||
viewStyle,
|
||
setViewStyle,
|
||
showSomeday,
|
||
setShowSomeday,
|
||
showAllDay,
|
||
setShowAllDay,
|
||
motto,
|
||
setMotto,
|
||
connections,
|
||
onUpdateConnections,
|
||
focusTimerDuration,
|
||
setFocusTimerDuration,
|
||
fontSize,
|
||
setFontSize,
|
||
showNextTask,
|
||
setShowNextTask
|
||
}: SettingsModalProps) {
|
||
const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'account'>('general');
|
||
// connections state removed (lifted)
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [exportStartDate, setExportStartDate] = useState('');
|
||
const [exportEndDate, setExportEndDate] = useState('');
|
||
|
||
// Account State
|
||
const [profile, setProfile] = useState<{
|
||
name: string;
|
||
email: string;
|
||
timezone: string;
|
||
autoRolling?: boolean;
|
||
protectEventTimes?: boolean;
|
||
language?: string;
|
||
dateFormat?: string;
|
||
timeFormat?: string;
|
||
startHour?: number;
|
||
endHour?: number;
|
||
focusTimerDuration?: number;
|
||
showTimeGrid?: boolean;
|
||
cellDuration?: number;
|
||
viewStyle?: string;
|
||
fontSize?: 'S' | 'M' | 'L';
|
||
showNextTask?: boolean;
|
||
}>({
|
||
name: '',
|
||
email: '',
|
||
timezone: 'Europe/Berlin',
|
||
autoRolling: false,
|
||
protectEventTimes: false,
|
||
language: 'de',
|
||
dateFormat: 'yyyy-MM-dd',
|
||
timeFormat: '24h',
|
||
startHour: 8,
|
||
endHour: 18,
|
||
focusTimerDuration: 25,
|
||
showTimeGrid: true,
|
||
cellDuration: 30,
|
||
viewStyle: 'list',
|
||
fontSize: 'M',
|
||
showNextTask: false
|
||
});
|
||
|
||
// Draggable/Resizable Modal State
|
||
const [modalPos, setModalPos] = useState({ x: 0, y: 0 });
|
||
const [modalSize, setModalSize] = useState({ width: 500, height: 750 });
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [isResizing, setIsResizing] = useState(false);
|
||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||
const [resizeStart, setResizeStart] = useState({ w: 0, h: 0, x: 0, y: 0 });
|
||
|
||
const t = translations[profile.language || 'en'] || translations['en'];
|
||
const [passwords, setPasswords] = useState({ new: '', confirm: '' });
|
||
const [accountMsg, setAccountMsg] = useState('');
|
||
|
||
useEffect(() => {
|
||
// fetchConnections removed (lifted)
|
||
fetchProfile();
|
||
}, []);
|
||
|
||
// fetchConnections function removed
|
||
|
||
|
||
// Add event listeners for dragging and resizing
|
||
useEffect(() => {
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
if (isDragging) {
|
||
setModalPos({
|
||
x: e.clientX - dragStart.x,
|
||
y: e.clientY - dragStart.y
|
||
});
|
||
}
|
||
if (isResizing) {
|
||
const newWidth = Math.max(400, resizeStart.w + (e.clientX - resizeStart.x));
|
||
const newHeight = Math.max(300, resizeStart.h + (e.clientY - resizeStart.y));
|
||
setModalSize({ width: newWidth, height: newHeight });
|
||
}
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
setIsDragging(false);
|
||
setIsResizing(false);
|
||
};
|
||
|
||
if (isDragging || isResizing) {
|
||
window.addEventListener('mousemove', handleMouseMove);
|
||
window.addEventListener('mouseup', handleMouseUp);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener('mousemove', handleMouseMove);
|
||
window.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
}, [isDragging, isResizing, dragStart, resizeStart]);
|
||
|
||
|
||
async function fetchProfile() {
|
||
try {
|
||
const res = await fetch('/api/user/profile');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.user) {
|
||
setProfile({
|
||
name: data.user.name || '',
|
||
email: data.user.email || '',
|
||
timezone: data.user.timezone || 'Europe/Berlin',
|
||
autoRolling: data.user.autoRolling || false,
|
||
protectEventTimes: data.user.protectEventTimes || false,
|
||
language: data.user.language || 'de',
|
||
dateFormat: data.user.dateFormat || 'yyyy-MM-dd',
|
||
timeFormat: data.user.timeFormat || '24h',
|
||
startHour: data.user.startHour !== undefined ? data.user.startHour : 8,
|
||
endHour: data.user.endHour !== undefined ? data.user.endHour : 18,
|
||
focusTimerDuration: data.user.focusTimerDuration || 25,
|
||
showTimeGrid: data.user.showTimeGrid !== undefined ? data.user.showTimeGrid : true,
|
||
cellDuration: data.user.cellDuration || 30,
|
||
viewStyle: data.user.viewStyle || 'list',
|
||
fontSize: data.user.fontSize || 'M'
|
||
});
|
||
|
||
if (data.user.showTimeGrid !== undefined) setShowTimeGrid(data.user.showTimeGrid);
|
||
if (data.user.cellDuration) setCellDuration(data.user.cellDuration as CellDuration);
|
||
if (data.user.viewStyle) setViewStyle(data.user.viewStyle as 'grid' | 'list');
|
||
if (data.user.fontSize) setFontSize(data.user.fontSize as 'S' | 'M' | 'L');
|
||
if (data.user.focusTimerDuration) setFocusTimerDuration(data.user.focusTimerDuration);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
const handleGoogleConnect = () => {
|
||
window.location.href = '/api/calendar/google/start';
|
||
};
|
||
|
||
const handleAppleConnect = () => {
|
||
alert('Apple Calendar integration coming soon! For now, you can import .ics files.');
|
||
};
|
||
|
||
const handleOutlookConnect = () => {
|
||
window.location.href = '/api/calendar/outlook/start';
|
||
};
|
||
|
||
const handleUpdateCalendar = async (connectionId: string, calendarId: string, updates: { selected?: boolean; editable?: boolean }) => {
|
||
// Optimistic Update
|
||
const updatedConnections = connections.map(conn => {
|
||
if (conn.id === connectionId && conn.calendars) {
|
||
return {
|
||
...conn,
|
||
calendars: conn.calendars.map((c: any) =>
|
||
c.id === calendarId ? { ...c, ...updates } : c
|
||
)
|
||
};
|
||
}
|
||
return conn;
|
||
});
|
||
|
||
onUpdateConnections(updatedConnections); // used props instead of setConnections
|
||
|
||
// API Call
|
||
try {
|
||
const conn = updatedConnections.find(c => c.id === connectionId);
|
||
if (conn) {
|
||
await fetch('/api/calendar/connections', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: connectionId,
|
||
calendars: conn.calendars
|
||
})
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update calendar selection', error);
|
||
// Revert on error - tough to do without refetching from parent or keeping prev state
|
||
}
|
||
};
|
||
|
||
|
||
|
||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
// Only validate password if in Account tab and password field is filled
|
||
if (activeTab === 'account' && passwords.new && passwords.new !== passwords.confirm) {
|
||
setAccountMsg('Passwords do not match');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/user/profile', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
...profile,
|
||
showTimeGrid: showTimeGrid,
|
||
cellDuration: cellDuration,
|
||
viewStyle: viewStyle,
|
||
showNextTask: showNextTask,
|
||
password: (passwords.new && passwords.new.trim() !== "") ? passwords.new : undefined
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (res.ok) {
|
||
setAccountMsg('Profile updated successfully!');
|
||
|
||
// Update local app state
|
||
if (onSettingsChanged) {
|
||
onSettingsChanged({
|
||
showTimeGrid: profile.showTimeGrid !== undefined ? profile.showTimeGrid : true,
|
||
cellDuration: (profile.cellDuration || 30) as CellDuration,
|
||
viewStyle: (profile.viewStyle || 'list') as 'grid' | 'list',
|
||
language: profile.language || 'en',
|
||
dateFormat: profile.dateFormat || 'MM/dd/yyyy',
|
||
timeFormat: profile.timeFormat || '12h',
|
||
startHour: profile.startHour || 8,
|
||
endHour: profile.endHour || 18,
|
||
fontSize: (profile.fontSize || 'M') as 'S' | 'M' | 'L',
|
||
showNextTask: showNextTask,
|
||
});
|
||
}
|
||
|
||
if (profile.focusTimerDuration && setFocusTimerDuration) {
|
||
setFocusTimerDuration(profile.focusTimerDuration);
|
||
}
|
||
|
||
// Temporary success message
|
||
setTimeout(() => setAccountMsg(''), 3000);
|
||
} else {
|
||
setAccountMsg(data.error || 'Failed to update profile');
|
||
if (data.details) console.error('Update profile details:', data.details);
|
||
}
|
||
} catch (e) {
|
||
console.error('Error updating profile:', e);
|
||
setAccountMsg('Error updating profile');
|
||
}
|
||
};
|
||
|
||
const handleDownloadData = () => {
|
||
window.open('/api/user/export', '_blank');
|
||
};
|
||
|
||
const handleDeleteAccount = async () => {
|
||
if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) return;
|
||
|
||
try {
|
||
const res = await fetch('/api/user/profile', { method: 'DELETE' });
|
||
if (res.ok) {
|
||
window.location.href = '/';
|
||
} else {
|
||
alert('Failed to delete account');
|
||
}
|
||
} catch (e) {
|
||
alert('Error deleting account');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="weekly-settings-overlay"
|
||
onClick={onClose}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
overflow: 'hidden'
|
||
}}
|
||
>
|
||
<div
|
||
className="weekly-settings-modal"
|
||
onClick={e => e.stopPropagation()}
|
||
style={{
|
||
position: 'relative',
|
||
width: `${modalSize.width}px`,
|
||
height: `${modalSize.height}px`,
|
||
transform: `translate(${modalPos.x}px, ${modalPos.y}px)`,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
maxHeight: '90vh',
|
||
maxWidth: '95vw',
|
||
resize: 'none'
|
||
}}
|
||
>
|
||
<header
|
||
className="weekly-settings-header"
|
||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||
onMouseDown={(e) => {
|
||
setIsDragging(true);
|
||
setDragStart({
|
||
x: e.clientX - modalPos.x,
|
||
y: e.clientY - modalPos.y
|
||
});
|
||
}}
|
||
>
|
||
<h2 className="weekly-settings-title">{t.settings}</h2>
|
||
<button className="weekly-settings-close" onClick={onClose}>×</button>
|
||
</header>
|
||
|
||
<div className="weekly-settings-tabs" style={{ display: 'flex', borderBottom: '1px solid #eee', padding: '0 24px' }}>
|
||
<button
|
||
onClick={() => setActiveTab('general')}
|
||
style={{ padding: '12px 16px', borderBottom: activeTab === 'general' ? '2px solid black' : 'none', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', opacity: activeTab === 'general' ? 1 : 0.6 }}
|
||
>
|
||
{t.general}
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('calendar')}
|
||
style={{ padding: '12px 16px', borderBottom: activeTab === 'calendar' ? '2px solid black' : 'none', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', opacity: activeTab === 'calendar' ? 1 : 0.6 }}
|
||
>
|
||
{t.calendar}
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('account')}
|
||
style={{ padding: '12px 16px', borderBottom: activeTab === 'account' ? '2px solid black' : 'none', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', opacity: activeTab === 'account' ? 1 : 0.6 }}
|
||
>
|
||
{t.account}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="weekly-settings-content">
|
||
{activeTab === 'general' ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||
{/* Motto */}
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.mottoOfWeek}</label>
|
||
<input
|
||
type="text"
|
||
value={motto}
|
||
onChange={(e) => setMotto(e.target.value)}
|
||
className="weekly-input"
|
||
placeholder={t.mottoOfWeek}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Visibility Toggles */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="showSomeday"
|
||
checked={showSomeday}
|
||
onChange={e => setShowSomeday(e.target.checked)}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="showSomeday" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.showSomeday}
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="showAllDay"
|
||
checked={showAllDay}
|
||
onChange={e => setShowAllDay(e.target.checked)}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="showAllDay" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.showAllDay}
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="autoRolling"
|
||
checked={profile.autoRolling || false}
|
||
onChange={e => setProfile({ ...profile, autoRolling: e.target.checked })}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="autoRolling" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.runningList}
|
||
</label>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="protectEventTimes"
|
||
checked={profile.protectEventTimes || false}
|
||
onChange={e => setProfile({ ...profile, protectEventTimes: e.target.checked })}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="protectEventTimes" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.protectEventTimes}
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="showTimeGrid"
|
||
checked={showTimeGrid}
|
||
onChange={e => setShowTimeGrid(e.target.checked)}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="showTimeGrid" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.showTimeGrid}
|
||
</label>
|
||
</div>
|
||
|
||
{showTimeGrid && (
|
||
<div style={{ marginLeft: '24px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.timeSlotDuration}</label>
|
||
<select
|
||
value={cellDuration}
|
||
onChange={(e) => setCellDuration(Number(e.target.value) as CellDuration)}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value={15}>15 min</option>
|
||
<option value={30}>30 min</option>
|
||
<option value={60}>1 hour</option>
|
||
<option value={120}>2 hours</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.viewStyle}</label>
|
||
<select
|
||
value={viewStyle}
|
||
onChange={(e) => setViewStyle(e.target.value as 'grid' | 'list')}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="grid">{t.gridView}</option>
|
||
<option value="list">{t.listView}</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Configurable Hours */}
|
||
<div style={{ display: 'flex', gap: '12px' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.startHour}</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
max="23"
|
||
value={profile.startHour}
|
||
onChange={(e) => setProfile(prev => ({ ...prev, startHour: parseInt(e.target.value) }))}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.endHour}</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="24"
|
||
value={profile.endHour}
|
||
onChange={(e) => setProfile(prev => ({ ...prev, endHour: parseInt(e.target.value) }))}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ borderTop: '1px solid #eee', marginTop: '16px', paddingTop: '16px' }}></div>
|
||
<h4 style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{t.localization}</h4>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.language}</label>
|
||
<select
|
||
value={profile.language}
|
||
onChange={e => setProfile({ ...profile, language: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="en">English</option>
|
||
<option value="de">German</option>
|
||
<option value="fr">French</option>
|
||
<option value="es">Spanish</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.dateFormat}</label>
|
||
<select
|
||
value={profile.dateFormat}
|
||
onChange={e => setProfile({ ...profile, dateFormat: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="MM/dd/yyyy">MM/DD/YYYY</option>
|
||
<option value="dd/MM/yyyy">DD/MM/YYYY</option>
|
||
<option value="yyyy-MM-dd">YYYY-MM-DD</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.timeFormat}</label>
|
||
<select
|
||
value={profile.timeFormat}
|
||
onChange={e => setProfile({ ...profile, timeFormat: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="12h">12h AM/PM</option>
|
||
<option value="24h">24H</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Text Size setting */}
|
||
<div className="settings-item" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||
<label style={{ fontWeight: 500, color: '#4b5563' }}>Text size</label>
|
||
<div className="weekly-toggle-group" style={{ display: 'flex', background: '#f3f4f6', borderRadius: '6px', padding: '2px' }}>
|
||
{(['S', 'M', 'L'] as const).map((size) => (
|
||
<button
|
||
key={size}
|
||
type="button"
|
||
onClick={() => {
|
||
setProfile({ ...profile, fontSize: size });
|
||
setFontSize(size);
|
||
}}
|
||
style={{
|
||
padding: '6px 12px',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
fontSize: '0.75rem',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
background: (profile.fontSize || fontSize) === size ? 'white' : 'transparent',
|
||
color: (profile.fontSize || fontSize) === size ? '#111827' : '#6b7280',
|
||
boxShadow: (profile.fontSize || fontSize) === size ? '0 1px 2px rgba(0,0,0,0.1)' : 'none',
|
||
transition: 'all 0.2s ease'
|
||
}}
|
||
>
|
||
{size}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="showNextTask"
|
||
checked={showNextTask || false}
|
||
onChange={(e) => setShowNextTask(e.target.checked)}
|
||
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
|
||
/>
|
||
<label htmlFor="showNextTask" style={{ cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 }}>
|
||
Show "Do This Now" instead of Motto
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}>Focus Timer Duration (minutes)</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="120"
|
||
value={profile.focusTimerDuration || 25}
|
||
onChange={(e) => setProfile({ ...profile, focusTimerDuration: parseInt(e.target.value) || 25 })}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleUpdateProfile} // Reuse handleUpdateProfile to save
|
||
className="weekly-btn-primary"
|
||
style={{ marginTop: '8px', padding: '10px', alignSelf: 'flex-start' }}
|
||
>
|
||
{t.saveChanges}
|
||
</button>
|
||
{accountMsg && <p style={{ fontSize: '0.9rem', color: accountMsg.includes('success') ? 'green' : 'red', marginTop: '8px' }}>{accountMsg}</p>}
|
||
</div>
|
||
) : activeTab === 'calendar' ? (
|
||
isLoading ? (
|
||
<p>Loading connections...</p>
|
||
) : (
|
||
<>
|
||
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600 }}>{t.connectedCalendars}</h3>
|
||
|
||
{connections.length === 0 ? (
|
||
<p style={{ color: 'var(--weekly-text-light)', marginBottom: '1.5rem' }}>
|
||
{t.noCalendars}
|
||
</p>
|
||
) : (
|
||
<ul style={{ marginBottom: '1.5rem', listStyle: 'none', padding: 0 }}>
|
||
{connections.map(conn => (
|
||
<li key={conn.id} style={{ padding: '1rem 0', borderBottom: '1px solid var(--weekly-border)' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span>{conn.provider === 'google' ? '📅' : '🍎'}</span>
|
||
{conn.provider === 'google' ? 'Google Calendar' : 'Apple Calendar'}
|
||
</div>
|
||
|
||
{/* Calendar Selection List */}
|
||
{conn.calendars && Array.isArray(conn.calendars) && conn.calendars.length > 0 ? (
|
||
<ul style={{ paddingLeft: '24px', listStyle: 'none' }}>
|
||
{conn.calendars.map((cal: any) => (
|
||
<li key={cal.id} style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '8px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={cal.selected !== false}
|
||
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { selected: e.target.checked })}
|
||
style={{ cursor: 'pointer' }}
|
||
/>
|
||
<span style={{ fontSize: '0.9rem', color: '#555' }}>
|
||
{cal.title} {cal.isPrimary && <span style={{ fontSize: '0.8em', color: '#888' }}>(Primary)</span>}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Editable toggle */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', opacity: 0.8 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={cal.editable === true}
|
||
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { editable: e.target.checked })}
|
||
style={{ cursor: 'pointer', width: '12px', height: '12px' }}
|
||
title="Allow adding/editing events"
|
||
/>
|
||
<span style={{ fontSize: '0.8rem', color: '#888' }} title="Allow adding/editing events">Editable</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<div style={{ fontSize: '0.85rem', color: '#888', paddingLeft: '24px' }}>
|
||
{conn.provider === 'google' ? 'No calendars found or permission denied.' : 'Selection available after connect.'}
|
||
</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600 }}>{t.connectMore}</h3>
|
||
|
||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||
<button onClick={handleGoogleConnect} className="calendar-connect-btn">
|
||
<span>📅</span> {t.connectGoogle}
|
||
</button>
|
||
<button onClick={handleAppleConnect} className="calendar-connect-btn">
|
||
<span>🍎</span> {t.connectApple}
|
||
</button>
|
||
<button onClick={handleOutlookConnect} className="calendar-connect-btn">
|
||
<span>📧</span> Connect Outlook
|
||
</button>
|
||
</div>
|
||
</>
|
||
)
|
||
) : (
|
||
/* Account Tab */
|
||
/* Account Tab */
|
||
/* Account Tab */
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||
<form onSubmit={handleUpdateProfile} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.name}</label>
|
||
<input
|
||
type="text"
|
||
value={profile.name}
|
||
onChange={e => setProfile({ ...profile, name: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.email}</label>
|
||
<input
|
||
type="email"
|
||
value={profile.email}
|
||
disabled
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #eee', borderRadius: '4px', background: '#f5f5f5', color: '#555' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.timezone}</label>
|
||
<select
|
||
value={profile.timezone}
|
||
onChange={e => setProfile({ ...profile, timezone: e.target.value })}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="UTC">UTC</option>
|
||
<option value="Europe/Berlin">Europe/Berlin</option>
|
||
<option value="America/New_York">America/New_York</option>
|
||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ borderTop: '1px solid #eee', paddingTop: '12px', marginTop: '8px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.changePassword}</label>
|
||
<input
|
||
type="password"
|
||
placeholder={t.newPassword}
|
||
value={passwords.new}
|
||
onChange={e => setPasswords({ ...passwords, new: e.target.value })}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', marginBottom: '8px' }}
|
||
/>
|
||
<input
|
||
type="password"
|
||
placeholder={t.confirmPassword}
|
||
value={passwords.confirm}
|
||
onChange={e => setPasswords({ ...passwords, confirm: e.target.value })}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
<small className="help-text" style={{ fontSize: '0.75rem', color: '#666', marginTop: '4px', display: 'block' }}>{translations[profile.language || 'en']?.newPasswordDesc || translations['en'].newPasswordDesc}</small>
|
||
</div>
|
||
|
||
{accountMsg && <div className={`weekly-auth-message ${accountMsg.includes('Success') ? 'success' : 'error'}`}>{accountMsg}</div>}
|
||
|
||
<button
|
||
type="submit"
|
||
className="weekly-auth-button primary"
|
||
style={{ marginTop: '8px' }}
|
||
>
|
||
{t.saveChanges}
|
||
</button>
|
||
</form>
|
||
|
||
{/* Data Export Section */}
|
||
<div style={{ marginTop: '30px', paddingTop: '20px', borderTop: '1px solid #eee' }}>
|
||
<h4 style={{ marginBottom: '10px', fontSize: '1.1rem' }}>{(profile.language === 'de') ? 'Datenexport' : 'Data Export'}</h4>
|
||
<p style={{ fontSize: '0.9rem', color: '#666', marginBottom: '15px' }}>
|
||
{(profile.language === 'de') ? 'Laden Sie eine CSV-Datei Ihrer erledigten Aufgaben herunter.' : 'Download a CSV file of your completed tasks.'}
|
||
</p>
|
||
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, marginBottom: '4px' }}>Start</label>
|
||
<input
|
||
type="date"
|
||
value={exportStartDate}
|
||
onChange={(e) => setExportStartDate(e.target.value)}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '6px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, marginBottom: '4px' }}>End</label>
|
||
<input
|
||
type="date"
|
||
value={exportEndDate}
|
||
onChange={(e) => setExportEndDate(e.target.value)}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '6px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<a
|
||
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}`}
|
||
target="_blank"
|
||
className="weekly-auth-button"
|
||
style={{ display: 'inline-flex', textDecoration: 'none', background: '#f8fafc', border: '1px solid #e2e8f0', color: '#0f172a', justifyContent: 'center' }}
|
||
>
|
||
{(profile.language === 'de') ? 'Erledigte Aufgaben exportieren (CSV)' : 'Export Completed Tasks (CSV)'}
|
||
</a>
|
||
</div>
|
||
|
||
<div className="account-danger-zone" style={{ marginTop: '30px', paddingTop: '20px', borderTop: '1px solid #eee' }}>
|
||
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '10px' }}>{t.dataPrivacy}</h3>
|
||
<div style={{ display: 'flex', gap: '10px' }}>
|
||
<button
|
||
onClick={handleDownloadData}
|
||
style={{ padding: '8px 12px', border: '1px solid #ddd', background: 'white', borderRadius: '4px', cursor: 'pointer' }}
|
||
>
|
||
{t.downloadData}
|
||
</button>
|
||
<button
|
||
onClick={handleDeleteAccount}
|
||
style={{ padding: '8px 12px', border: '1px solid #d32f2f', background: 'white', color: '#d32f2f', borderRadius: '4px', cursor: 'pointer' }}
|
||
>
|
||
{t.deleteAccount}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Resize Handle */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: 0,
|
||
right: 0,
|
||
width: '20px',
|
||
height: '20px',
|
||
cursor: 'nwse-resize',
|
||
zIndex: 100
|
||
}}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
setIsResizing(true);
|
||
setResizeStart({
|
||
w: modalSize.width,
|
||
h: modalSize.height,
|
||
x: e.clientX,
|
||
y: e.clientY
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
</div >
|
||
);
|
||
}
|
||
|
||
|