- New "Kanban" view style alongside Simple, Calendar, and List - Drag tasks between columns to change their stage - Customizable stages with colors in Settings > View Style - Stage colors appear as left border indicators on tasks in weekly view - Default stages: Backlog, To Do, In Progress, Review, Done - Stages persist in database (User.kanbanStages as JSON) - Task stage persists in database (Task.kanbanStage) - Full i18n support (EN, DE, FR, ES, IT) - Unassigned tasks shown in separate column v1.28.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13973 lines
745 KiB
TypeScript
13973 lines
745 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 { GridTaskBlock } from "./GridTaskBlock";
|
||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||
import { faApple, faGoogle, faMicrosoft } from "@fortawesome/free-brands-svg-icons";
|
||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||
|
||
import FocusModeOverlay from "./FocusModeOverlay";
|
||
import {
|
||
LayoutGrid,
|
||
Calendar,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
Search,
|
||
Settings,
|
||
User,
|
||
Clock,
|
||
Menu,
|
||
Target,
|
||
Sun,
|
||
Moon,
|
||
Repeat,
|
||
GripVertical,
|
||
Play,
|
||
Zap,
|
||
Plus,
|
||
RefreshCcw,
|
||
Layout,
|
||
Palette,
|
||
Sparkles,
|
||
Info,
|
||
Trash2,
|
||
Undo2,
|
||
Redo2,
|
||
AlertCircle,
|
||
MoreVertical,
|
||
Check,
|
||
Eye,
|
||
EyeOff,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
Type,
|
||
FolderOpen,
|
||
FolderPlus,
|
||
ListPlus,
|
||
Circle,
|
||
X,
|
||
Cable,
|
||
Link,
|
||
Globe,
|
||
Tag,
|
||
} from "lucide-react";
|
||
|
||
// Types
|
||
import UserMenu from "./UserMenu";
|
||
import SearchModal from "./SearchModal";
|
||
import SimpleDatePicker from "./SimpleDatePicker";
|
||
import RecurringTasksManager from "./RecurringTasksManager";
|
||
export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; }
|
||
import { ImportListModal } from "./ImportListModal";
|
||
import { getRandomLocalQuote } from "@/lib/quotes";
|
||
|
||
// Cookie helpers for per-device settings
|
||
const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour"];
|
||
|
||
function getCookie(name: string): string | null {
|
||
if (typeof document === "undefined") return null;
|
||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||
return match ? decodeURIComponent(match[1]) : null;
|
||
}
|
||
|
||
function setCookie(name: string, value: string, days: number = 365) {
|
||
if (typeof document === "undefined") return;
|
||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
|
||
}
|
||
|
||
export type ViewStyle = "simple" | "calendar" | "list" | "grid" | "kanban";
|
||
|
||
export interface KanbanStage {
|
||
id: string;
|
||
name: string;
|
||
color: string;
|
||
}
|
||
|
||
export interface Task {
|
||
id: string;
|
||
title: string;
|
||
completed: boolean;
|
||
dayOfWeek?: number | null;
|
||
scheduledDate?: string | null;
|
||
markdownContent?: string | null;
|
||
createdAt?: Date;
|
||
updatedAt: Date;
|
||
order: number;
|
||
completedAt?: Date | null;
|
||
externalId?: string | null;
|
||
externalProvider?: string | null;
|
||
lastSyncedAt?: Date | null;
|
||
syncStatus?: string | null;
|
||
subTasks?: Task[];
|
||
parentId?: string | null;
|
||
isRolling?: boolean;
|
||
isRecurring?: boolean;
|
||
somedayListId?: string | null;
|
||
somedaySlotIndex?: number | null;
|
||
repeatPattern?: string | null;
|
||
repeatEndDate?: string | null;
|
||
repeatStartDate?: string | null;
|
||
originalRecurringId?: string | null;
|
||
baseRecurringTask?: Task | null;
|
||
recurringExceptions?: RecurringTaskException[];
|
||
startTime?: string | null;
|
||
duration?: number | null;
|
||
parentTaskId?: string | null;
|
||
userId: string;
|
||
recurrenceInterval?: number | null;
|
||
recurrenceUnit?: string | null;
|
||
recurrenceTime?: string | null;
|
||
recurrenceEndDate?: Date | null;
|
||
recurrenceDays?: number[] | null;
|
||
externalListId?: string | null;
|
||
projectId?: string | null;
|
||
project?: { id: string; name: string; icon?: string | null; color?: string | null } | null;
|
||
kanbanStage?: string | null;
|
||
}
|
||
|
||
interface CalendarEvent {
|
||
id: string;
|
||
title: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
source: "google" | "apple" | "outlook" | "synology";
|
||
calendarId?: string;
|
||
calendarTitle?: string;
|
||
calendarColor?: string;
|
||
editable?: boolean;
|
||
}
|
||
|
||
interface SomedayList {
|
||
id: string;
|
||
title: string;
|
||
tasks: Task[];
|
||
tab?: string | null;
|
||
externalProvider?: string | null;
|
||
externalId?: string | null;
|
||
externalListId?: string | null;
|
||
}
|
||
|
||
// Time grid configuration options
|
||
type CellDuration = 15 | 30 | 60 | 120;
|
||
const DEFAULT_SOMEDAY_SLOT_COUNT = 5;
|
||
|
||
const getSomedaySlotCount = (tasks: Task[]) => {
|
||
const maxIdx = tasks.reduce((max, t) => {
|
||
if (t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined) {
|
||
return Math.max(max, t.somedaySlotIndex);
|
||
}
|
||
return max;
|
||
}, -1);
|
||
// Add 1 extra slot if more than 4 tasks exist, or at least 5 slots total.
|
||
// "add 5 rows and then when 4 are taken add another row"
|
||
// Let's ensure there's always at least one empty slot at the bottom.
|
||
return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2);
|
||
};
|
||
|
||
// Font options
|
||
const AVAILABLE_FONTS = [
|
||
{ name: "Default (Inter)", value: "Inter" },
|
||
{ name: "Roboto", value: "Roboto" },
|
||
{ name: "Open Sans", value: "Open Sans" },
|
||
{ name: "Lato", value: "Lato" },
|
||
{ name: "Montserrat", value: "Montserrat" },
|
||
{ name: "Oswald", value: "Oswald" },
|
||
{ name: "Raleway", value: "Raleway" },
|
||
{ name: "Playfair Display", value: "Playfair Display" },
|
||
{ name: "Merriweather", value: "Merriweather" },
|
||
{ name: "Nunito", value: "Nunito" },
|
||
{ name: "Dancing Script", value: "Dancing Script" },
|
||
{ name: "Pacifico", value: "Pacifico" },
|
||
{ name: "Custom Google Font...", value: "__custom__" },
|
||
];
|
||
|
||
// Check if a font value is a custom (non-preset) font
|
||
const isCustomFont = (value: string): boolean =>
|
||
!!value && value !== "__custom__" && !AVAILABLE_FONTS.slice(0, -1).some((f) => f.value === value);
|
||
|
||
const FONT_WEIGHTS = [
|
||
{ name: "Light", value: "300" },
|
||
{ name: "Normal", value: "400" },
|
||
{ name: "Medium", value: "500" },
|
||
{ name: "Bold", value: "700" },
|
||
];
|
||
|
||
// Helper to load Google Fonts
|
||
const useGoogleFonts = (fonts: string[]) => {
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const fontsToLoad = fonts.filter((f) => f && f !== "Inter");
|
||
if (fontsToLoad.length === 0) return;
|
||
|
||
const linkId = "google-fonts-link";
|
||
let link = document.getElementById(linkId) as HTMLLinkElement;
|
||
|
||
const fontQuery = fontsToLoad.map((f) => f.replace(" ", "+")).join("|");
|
||
const href = `https://fonts.googleapis.com/css2?family=${fontsToLoad.map((f) => `${f.replace(" ", "+")}:wght@300;400;500;700`).join("&family=")}&display=swap`;
|
||
|
||
if (!link) {
|
||
link = document.createElement("link");
|
||
link.id = linkId;
|
||
link.rel = "stylesheet";
|
||
document.head.appendChild(link);
|
||
}
|
||
link.href = href;
|
||
}, [fonts]);
|
||
};
|
||
|
||
// Translations
|
||
const translations: Record<string, any> = {
|
||
en: {
|
||
settings: "Settings",
|
||
general: "General",
|
||
calendar: "Connections",
|
||
localisation: "Localisation",
|
||
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",
|
||
simpleView: "Simple",
|
||
calendarView: "Calendar",
|
||
listView: "List",
|
||
kanbanView: "Kanban",
|
||
kanbanStages: "Kanban Stages",
|
||
kanbanStagesDesc: "Define the stages for your Kanban board. Drag tasks between columns to change their stage.",
|
||
addStage: "Add stage",
|
||
stageName: "Stage name",
|
||
noStage: "No stage",
|
||
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",
|
||
connectSynology: "Connect Synology",
|
||
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",
|
||
newList: "New list",
|
||
allTabs: "All",
|
||
newTab: "New tab",
|
||
newTabName: "New tab name:",
|
||
assignTab: "Assign to tab",
|
||
noTab: "No tab",
|
||
renameTab: "Double-click to rename",
|
||
dissolveTab: "Remove tab (keep 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",
|
||
goalOfWeek: "Goal of the Week",
|
||
goalScope: "Goal Scope",
|
||
goalScopeWeek: "Per Week",
|
||
goalScopeDay: "Per Day",
|
||
goalFallback: "Goal Fallback Type",
|
||
defaultGoal: "Custom Default Goal",
|
||
showTaskCheckboxes: "Show Checkboxes on Tasks",
|
||
showSomeday: "Show Someday Section",
|
||
showAllDay: "Show All-Day Section",
|
||
allDayPosition: "All-Day Events Position",
|
||
allDayAbove: "Above",
|
||
allDayBelow: "Below",
|
||
newPasswordDesc: "Leave blank to keep current password.",
|
||
dateAlignment: "Date Alignment",
|
||
dateVerticalAlign: "Date Vertical Alignment",
|
||
alignTop: "Top",
|
||
alignMiddle: "Middle",
|
||
alignBottom: "Bottom",
|
||
dateLayout: "Date Layout",
|
||
alignmentLeft: "Left",
|
||
alignmentCenter: "Center",
|
||
alignmentRight: "Right",
|
||
alignmentTight: "Tight",
|
||
backupRestore: "Backup & Restore",
|
||
backupRestoreDesc: "Export all your tasks, anyday lists, and projects as a JSON file. You can edit the file and import it back.",
|
||
exportAllData: "Export All Data (JSON)",
|
||
importData: "Import Data",
|
||
importMode: "Import Mode",
|
||
importModeMerge: "Merge",
|
||
importModeMergeDesc: "Add imported data alongside existing tasks",
|
||
importModeReplace: "Replace",
|
||
importModeReplaceDesc: "Delete all existing data and replace with imported data",
|
||
importReplaceWarning: "Warning: This will permanently delete all your current tasks, lists, and projects!",
|
||
importSelectFile: "Select JSON file...",
|
||
importButton: "Import",
|
||
importing: "Importing...",
|
||
exporting: "Exporting...",
|
||
projects: "Projects",
|
||
projectsDesc: "Organize tasks with color-coded projects",
|
||
addProject: "Add Project",
|
||
projectName: "Name",
|
||
projectColor: "Color",
|
||
noProjects: "No projects yet",
|
||
assignProject: "Assign project",
|
||
removeProject: "Remove project",
|
||
weekdayFormat: "Weekday Format",
|
||
weekdayFormatFull: "Full Name (Monday)",
|
||
weekdayFormatShort: "Short (Mon)",
|
||
weekdayFormatNarrow: "Narrow (M)",
|
||
weekdayFormatCustom: "Custom",
|
||
customWeekdayNamesMon: "Mo; Tu; We; Th; Fr; Sa; Su",
|
||
customWeekdayNamesSun: "Su; Mo; Tu; We; Th; Fr; Sa",
|
||
weekdayCase: "Weekday Case",
|
||
weekdayCaseNormal: "Normal (monday)",
|
||
weekdayCaseCapitalize: "Capitalize (Monday)",
|
||
weekdayCaseUppercase: "Uppercase (MONDAY)",
|
||
styling: "Styling",
|
||
motivation: "Motivation",
|
||
about: "About",
|
||
weekStartLabel: "Start week on",
|
||
startViewLabel: "Start view on",
|
||
monday: "Monday",
|
||
sunday: "Sunday",
|
||
today: "Today",
|
||
yesterday: "Yesterday",
|
||
accountId: "Account ID",
|
||
accountIdDesc: "Your unique account identifier",
|
||
accountNumberLabel: "Account Number",
|
||
accountNumberDesc: "Your account number for identification when changing email",
|
||
connectOutlook: "Connect Outlook",
|
||
syncTasks: "Sync Tasks",
|
||
syncTasksDesc: "Sync tasks with Google Tasks or Microsoft To-Do.",
|
||
unsyncConfirmMsg: "Stop syncing \"{title}\"? Its tasks will be moved to trash.",
|
||
unsyncConfirm: "Stop syncing",
|
||
unsyncCancel: "Cancel",
|
||
syncAll: "Sync all",
|
||
unsyncAll: "Unsync all",
|
||
fetchingLists: "(fetching lists...)",
|
||
listHeader: "List",
|
||
syncHeader: "Sync",
|
||
noTaskListsFound: "No task lists found.",
|
||
connectProviderAbove: "Connect a provider above to sync task lists.",
|
||
noCalendarsFound: "No calendars found or permission denied.",
|
||
noCalendarsApple: "No calendars loaded. Please disconnect and reconnect Apple Calendar.",
|
||
noCalendarsSynology: "No calendars loaded. Please disconnect and reconnect Synology.",
|
||
selectionAfterConnect: "Selection available after connect.",
|
||
sharedCalendar: "Shared calendar",
|
||
primaryCalendar: "(Primary)",
|
||
fontCustomization: "Font Customization",
|
||
dateLayoutRight: "Date Right of Weekday",
|
||
dateLayoutLeft: "Date Left of Weekday",
|
||
dateLayoutAbove: "Date Above Weekday",
|
||
dateLayoutBelow: "Date Below Weekday",
|
||
dateLayoutHidden: "Date Hidden",
|
||
dateLayoutMobile: "Date Layout (Mobile)",
|
||
dayWeekdayGap: "Day / Weekday Gap",
|
||
weekdayFont: "Weekday Font",
|
||
dateFont: "Date Font",
|
||
taskFont: "Task Font",
|
||
eventFont: "Event Font",
|
||
goalFont: "Goal / Quote Font",
|
||
cwFont: "Calendar Week Font",
|
||
yearFont: "Year Font",
|
||
fontPlaceholder: "e.g. Poppins, Bebas Neue...",
|
||
fontSizePlaceholder: "Font size (e.g. 1.25rem)",
|
||
weightLight: "Light",
|
||
weightNormal: "Normal",
|
||
weightMedium: "Medium",
|
||
weightSemi: "Semi",
|
||
weightBold: "Bold",
|
||
weightBlack: "Black",
|
||
hourLabelFormat: "Hour Label Format",
|
||
hourLabelShort: "Short (8, 9, 10)",
|
||
hourLabelFull: "Full (8:00, 9:00, 10:00)",
|
||
showSubhourLabels: "Show Sub-hour Labels (:15, :30, :45)",
|
||
showScheduleCalendar: "Show Schedule / Calendar",
|
||
showDoThisNow: 'Show "Do This Now" instead of Motto',
|
||
focusTimer: "Focus Timer (min)",
|
||
focusBreak: "Focus Break (min)",
|
||
goalScopeTitle: "Goal Time Period",
|
||
goalFallbackTitle: "Goal Fallback",
|
||
motivationalQuote: "Motivational Quote / Holiday Hint",
|
||
nextTodo: "Next To-Do",
|
||
defaultText: "Default Text",
|
||
apiDataSources: "API Data Sources (URLs)",
|
||
addSource: "Add Source",
|
||
urlFormatHelp: "URL returning JSON quotes",
|
||
quoteLanguages: "Quote Languages",
|
||
quoteLanguagesDesc: "Choose which languages your quotes appear in. At least one must be selected.",
|
||
quoteFallbackDesc: "If no external source responds, curated local quotes in your language are used as fallback.",
|
||
defaultGoalPlaceholder: "Enter your goal here...",
|
||
saturdayColor: "Saturday",
|
||
sundayColor: "Sunday",
|
||
todayHighlight: "Today Highlight",
|
||
pastDayColor: "Past Day Color",
|
||
deleteProjectConfirm: "Delete project",
|
||
importConfirmReplace: "This will delete ALL existing tasks, lists, and projects. Continue?",
|
||
importSuccess: "Import complete",
|
||
importInvalidJson: "Invalid JSON file",
|
||
},
|
||
de: {
|
||
settings: "Einstellungen",
|
||
general: "Allgemein",
|
||
calendar: "Verbindungen",
|
||
localisation: "Lokalisierung",
|
||
account: "Konto",
|
||
runningList: "Laufende Liste (Aufgaben automatisch auf heute verschieben)",
|
||
protectEventTimes: "Ereigniszeiten schützen",
|
||
showTimeGrid: "Zeitplan anzeigen",
|
||
timeSlotDuration: "Zeitfensterdauer",
|
||
viewStyle: "Ansichtsstil",
|
||
simpleView: "Einfach",
|
||
calendarView: "Kalender",
|
||
kanbanView: "Kanban",
|
||
kanbanStages: "Kanban-Phasen",
|
||
kanbanStagesDesc: "Definiere die Phasen für dein Kanban-Board. Ziehe Aufgaben zwischen Spalten, um ihre Phase zu ändern.",
|
||
addStage: "Phase hinzufügen",
|
||
stageName: "Phasenname",
|
||
noStage: "Keine Phase",
|
||
listView: "Liste",
|
||
notes: "Notizen",
|
||
notesSidebar: "Notizen-Seitenleiste",
|
||
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",
|
||
newList: "Neue Liste",
|
||
allTabs: "Alle",
|
||
newTab: "Neuer Tab",
|
||
newTabName: "Neuer Tab-Name:",
|
||
assignTab: "Tab zuweisen",
|
||
noTab: "Kein Tab",
|
||
renameTab: "Doppelklick zum Umbenennen",
|
||
dissolveTab: "Tab entfernen (Listen behalten)",
|
||
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",
|
||
goalOfWeek: "Ziel der Woche",
|
||
goalScope: "Ziel-Zeitraum",
|
||
goalScopeWeek: "Pro Woche",
|
||
goalScopeDay: "Pro Tag",
|
||
goalFallback: "Ziel-Fallback-Typ",
|
||
defaultGoal: "Benutzerdefiniertes Standardziel",
|
||
showTaskCheckboxes: "Checkboxen bei Aufgaben anzeigen",
|
||
showSomeday: "Irgendwann-Bereich anzeigen",
|
||
showAllDay: "Ganztägige Ereignisse anzeigen",
|
||
allDayPosition: "Position ganztägiger Ereignisse",
|
||
allDayAbove: "Oben",
|
||
allDayBelow: "Unten",
|
||
newPasswordDesc: "Leer lassen, um das aktuelle Passwort zu behalten.",
|
||
dateAlignment: "Datums-Ausrichtung",
|
||
dateVerticalAlign: "Datums-Vertikalausrichtung",
|
||
alignTop: "Oben",
|
||
alignMiddle: "Mitte",
|
||
alignBottom: "Unten",
|
||
dateLayout: "Datumslayout",
|
||
alignmentLeft: "Links",
|
||
alignmentCenter: "Mitte",
|
||
alignmentRight: "Rechts",
|
||
alignmentTight: "Eng",
|
||
backupRestore: "Sicherung & Wiederherstellung",
|
||
backupRestoreDesc: "Exportieren Sie alle Aufgaben, Irgendwann-Listen und Projekte als JSON-Datei. Sie können die Datei bearbeiten und wieder importieren.",
|
||
exportAllData: "Alle Daten exportieren (JSON)",
|
||
importData: "Daten importieren",
|
||
importMode: "Import-Modus",
|
||
importModeMerge: "Zusammenführen",
|
||
importModeMergeDesc: "Importierte Daten neben bestehenden Aufgaben hinzufügen",
|
||
importModeReplace: "Ersetzen",
|
||
importModeReplaceDesc: "Alle bestehenden Daten löschen und durch importierte ersetzen",
|
||
importReplaceWarning: "Warnung: Dies löscht dauerhaft alle Ihre aktuellen Aufgaben, Listen und Projekte!",
|
||
importSelectFile: "JSON-Datei auswählen...",
|
||
importButton: "Importieren",
|
||
importing: "Importiere...",
|
||
exporting: "Exportiere...",
|
||
projects: "Projekte",
|
||
projectsDesc: "Aufgaben mit farbcodierten Projekten organisieren",
|
||
addProject: "Projekt hinzufügen",
|
||
projectName: "Name",
|
||
projectColor: "Farbe",
|
||
noProjects: "Noch keine Projekte",
|
||
assignProject: "Projekt zuweisen",
|
||
removeProject: "Projekt entfernen",
|
||
weekdayFormat: "Wochentag-Format",
|
||
weekdayFormatFull: "Vollständiger Name (Montag)",
|
||
weekdayFormatShort: "Kurz (Mo)",
|
||
weekdayFormatNarrow: "Schmal (M)",
|
||
weekdayFormatCustom: "Benutzerdefiniert",
|
||
customWeekdayNamesMon: "Mo; Di; Mi; Do; Fr; Sa; So",
|
||
customWeekdayNamesSun: "So; Mo; Di; Mi; Do; Fr; Sa",
|
||
weekdayCase: "Groß-/Kleinschreibung",
|
||
weekdayCaseNormal: "Klein (montag)",
|
||
weekdayCaseCapitalize: "Großbuchstabe (Montag)",
|
||
weekdayCaseUppercase: "Großbuchstaben (MONTAG)",
|
||
styling: "Design",
|
||
motivation: "Motivation",
|
||
about: "Über",
|
||
weekStartLabel: "Woche beginnt am",
|
||
startViewLabel: "Ansicht beginnt mit",
|
||
monday: "Montag",
|
||
sunday: "Sonntag",
|
||
today: "Heute",
|
||
yesterday: "Gestern",
|
||
accountId: "Konto-ID",
|
||
accountIdDesc: "Ihre eindeutige Konto-Kennung",
|
||
accountNumberLabel: "Kontonummer",
|
||
accountNumberDesc: "Ihre Kontonummer zur Identifikation",
|
||
connectOutlook: "Outlook verbinden",
|
||
syncTasks: "Aufgaben synchronisieren",
|
||
syncTasksDesc: "Aufgaben mit Google Tasks oder Microsoft To-Do synchronisieren.",
|
||
unsyncConfirmMsg: "Synchronisierung von \"{title}\" beenden? Die Aufgaben werden in den Papierkorb verschoben.",
|
||
unsyncConfirm: "Sync beenden",
|
||
unsyncCancel: "Abbrechen",
|
||
syncAll: "Alle synchronisieren",
|
||
unsyncAll: "Alle trennen",
|
||
fetchingLists: "(Listen werden geladen...)",
|
||
listHeader: "Liste",
|
||
syncHeader: "Sync",
|
||
noTaskListsFound: "Keine Aufgabenlisten gefunden.",
|
||
connectProviderAbove: "Verbinden Sie einen Anbieter oben, um Aufgabenlisten zu synchronisieren.",
|
||
noCalendarsFound: "Keine Kalender gefunden oder Zugriff verweigert.",
|
||
noCalendarsApple: "Keine Kalender geladen. Bitte trennen und erneut verbinden.",
|
||
noCalendarsSynology: "Keine Kalender geladen. Bitte trennen und erneut verbinden.",
|
||
selectionAfterConnect: "Auswahl nach Verbindung verfügbar.",
|
||
sharedCalendar: "Geteilter Kalender",
|
||
primaryCalendar: "(Primär)",
|
||
fontCustomization: "Schriftart-Anpassung",
|
||
dateLayoutRight: "Datum rechts vom Wochentag",
|
||
dateLayoutLeft: "Datum links vom Wochentag",
|
||
dateLayoutAbove: "Datum über Wochentag",
|
||
dateLayoutBelow: "Datum unter Wochentag",
|
||
dateLayoutHidden: "Datum ausgeblendet",
|
||
dateLayoutMobile: "Datum-Layout (Mobil)",
|
||
dayWeekdayGap: "Tag / Wochentag Abstand",
|
||
weekdayFont: "Wochentag-Schrift",
|
||
dateFont: "Datum-Schrift",
|
||
taskFont: "Aufgaben-Schrift",
|
||
eventFont: "Termin-Schrift",
|
||
goalFont: "Ziel / Zitat-Schrift",
|
||
cwFont: "Kalenderwoche-Schrift",
|
||
yearFont: "Jahr-Schrift",
|
||
fontPlaceholder: "z.B. Poppins, Bebas Neue...",
|
||
fontSizePlaceholder: "Schriftgröße (z.B. 1.25rem)",
|
||
weightLight: "Leicht",
|
||
weightNormal: "Normal",
|
||
weightMedium: "Mittel",
|
||
weightSemi: "Halb-fett",
|
||
weightBold: "Fett",
|
||
weightBlack: "Schwarz",
|
||
hourLabelFormat: "Stundenformat",
|
||
hourLabelShort: "Kurz (8, 9, 10)",
|
||
hourLabelFull: "Voll (8:00, 9:00, 10:00)",
|
||
showSubhourLabels: "Viertelstunden anzeigen (:15, :30, :45)",
|
||
showScheduleCalendar: "Zeitplan / Kalender anzeigen",
|
||
showDoThisNow: '"Jetzt erledigen" statt Motto anzeigen',
|
||
focusTimer: "Fokus-Timer (Min)",
|
||
focusBreak: "Fokus-Pause (Min)",
|
||
goalScopeTitle: "Ziel-Zeitraum",
|
||
goalFallbackTitle: "Ziel-Fallback",
|
||
motivationalQuote: "Motivationszitat / Feiertags-Hinweis",
|
||
nextTodo: "Nächstes To-Do",
|
||
defaultText: "Standardtext",
|
||
apiDataSources: "API-Datenquellen (URLs)",
|
||
addSource: "Quelle hinzufügen",
|
||
urlFormatHelp: "URL die JSON-Zitate liefert",
|
||
quoteLanguages: "Zitatsprachen",
|
||
quoteLanguagesDesc: "Wählen Sie die Sprachen für Ihre Zitate. Mindestens eine muss ausgewählt sein.",
|
||
quoteFallbackDesc: "Wenn keine externe Quelle antwortet, werden lokale kuratierte Zitate in Ihrer Sprache verwendet.",
|
||
defaultGoalPlaceholder: "Ihr Ziel hier eingeben...",
|
||
saturdayColor: "Samstag",
|
||
sundayColor: "Sonntag",
|
||
todayHighlight: "Heute-Hervorhebung",
|
||
pastDayColor: "Vergangene Tage",
|
||
deleteProjectConfirm: "Projekt löschen",
|
||
importConfirmReplace: "Dies löscht ALLE bestehenden Aufgaben, Listen und Projekte. Fortfahren?",
|
||
importSuccess: "Import abgeschlossen",
|
||
importInvalidJson: "Ungültige JSON-Datei",
|
||
},
|
||
fr: {
|
||
settings: "Paramètres",
|
||
general: "Général",
|
||
calendar: "Connexions",
|
||
localisation: "Localisation",
|
||
account: "Compte",
|
||
runningList: "Liste continue (reporter les tâches à aujourd'hui)",
|
||
protectEventTimes: "Protéger les horaires des événements",
|
||
showTimeGrid: "Afficher la grille horaire",
|
||
timeSlotDuration: "Durée des créneaux horaires",
|
||
viewStyle: "Style d'affichage",
|
||
simpleView: "Simple",
|
||
calendarView: "Calendrier",
|
||
listView: "Liste",
|
||
kanbanView: "Kanban",
|
||
kanbanStages: "Étapes Kanban",
|
||
kanbanStagesDesc: "Définissez les étapes de votre tableau Kanban. Glissez les tâches entre les colonnes pour changer leur étape.",
|
||
addStage: "Ajouter une étape",
|
||
stageName: "Nom de l'étape",
|
||
noStage: "Aucune étape",
|
||
language: "Langue",
|
||
dateFormat: "Format de date",
|
||
timeFormat: "Format d'heure",
|
||
saveChanges: "Enregistrer",
|
||
connectedCalendars: "Calendriers connectés",
|
||
connectMore: "En connecter d'autres",
|
||
connectGoogle: "Connecter Google Agenda",
|
||
connectApple: "Connecter le calendrier Apple",
|
||
connectSynology: "Connecter Synology",
|
||
noCalendars: "Aucun calendrier connecté.",
|
||
dataPrivacy: "Données et confidentialité",
|
||
downloadData: "Télécharger mes données",
|
||
deleteAccount: "Supprimer le compte",
|
||
name: "Nom",
|
||
email: "E-mail",
|
||
timezone: "Fuseau horaire",
|
||
changePassword: "Changer le mot de passe",
|
||
newPassword: "Nouveau mot de passe",
|
||
confirmPassword: "Confirmer le mot de passe",
|
||
someday: "UN JOUR",
|
||
lists: "Listes",
|
||
newList: "Nouvelle liste",
|
||
allTabs: "Tous",
|
||
newTab: "Nouvel onglet",
|
||
newTabName: "Nom du nouvel onglet :",
|
||
assignTab: "Assigner à un onglet",
|
||
noTab: "Aucun onglet",
|
||
renameTab: "Double-cliquez pour renommer",
|
||
dissolveTab: "Supprimer l'onglet (garder les listes)",
|
||
loading: "Chargement de vos tâches…",
|
||
sycing: "Synchronisation…",
|
||
synced: "Synchronisé",
|
||
localization: "Localisation",
|
||
allDayEvents: "ÉVÉNEMENTS JOURNÉE ENTIÈRE",
|
||
syncCalendar: "Synchroniser le calendrier",
|
||
toggleDarkMode: "Basculer le mode sombre",
|
||
signOut: "Se déconnecter",
|
||
startHour: "Début de journée",
|
||
endHour: "Fin de journée",
|
||
weekAbbr: "S",
|
||
goalOfWeek: "Objectif de la semaine",
|
||
goalScope: "Portée de l'objectif",
|
||
goalScopeWeek: "Par semaine",
|
||
goalScopeDay: "Par jour",
|
||
goalFallback: "Type d'objectif par défaut",
|
||
defaultGoal: "Objectif par défaut personnalisé",
|
||
showTaskCheckboxes: "Afficher les cases à cocher",
|
||
showSomeday: "Afficher la section Un jour",
|
||
showAllDay: "Afficher la section Journée entière",
|
||
allDayPosition: "Position des événements journée entière",
|
||
allDayAbove: "Au-dessus",
|
||
allDayBelow: "En dessous",
|
||
newPasswordDesc: "Laisser vide pour conserver le mot de passe actuel.",
|
||
dateAlignment: "Alignement de la date",
|
||
dateVerticalAlign: "Alignement vertical de la date",
|
||
alignTop: "Haut",
|
||
alignMiddle: "Milieu",
|
||
alignBottom: "Bas",
|
||
dateLayout: "Disposition de la date",
|
||
alignmentLeft: "Gauche",
|
||
alignmentCenter: "Centre",
|
||
alignmentRight: "Droite",
|
||
alignmentTight: "Compact",
|
||
backupRestore: "Sauvegarde et restauration",
|
||
backupRestoreDesc: "Exportez toutes vos tâches, listes et projets au format JSON. Vous pouvez modifier le fichier et le réimporter.",
|
||
exportAllData: "Exporter toutes les données (JSON)",
|
||
importData: "Importer des données",
|
||
importMode: "Mode d'importation",
|
||
importModeMerge: "Fusionner",
|
||
importModeMergeDesc: "Ajouter les données importées aux tâches existantes",
|
||
importModeReplace: "Remplacer",
|
||
importModeReplaceDesc: "Supprimer toutes les données existantes et les remplacer par les données importées",
|
||
importReplaceWarning: "Attention : toutes vos tâches, listes et projets actuels seront définitivement supprimés !",
|
||
importSelectFile: "Sélectionner un fichier JSON…",
|
||
importButton: "Importer",
|
||
importing: "Importation…",
|
||
exporting: "Exportation…",
|
||
projects: "Projets",
|
||
projectsDesc: "Organisez vos tâches avec des projets colorés",
|
||
addProject: "Ajouter un projet",
|
||
projectName: "Nom",
|
||
projectColor: "Couleur",
|
||
noProjects: "Aucun projet",
|
||
assignProject: "Attribuer un projet",
|
||
removeProject: "Retirer le projet",
|
||
weekdayFormat: "Format des jours",
|
||
weekdayFormatFull: "Nom complet (lundi)",
|
||
weekdayFormatShort: "Abrégé (lun.)",
|
||
weekdayFormatNarrow: "Étroit (L)",
|
||
weekdayFormatCustom: "Personnalisé",
|
||
customWeekdayNamesMon: "Lu; Ma; Me; Je; Ve; Sa; Di",
|
||
customWeekdayNamesSun: "Di; Lu; Ma; Me; Je; Ve; Sa",
|
||
weekdayCase: "Casse des jours",
|
||
weekdayCaseNormal: "Normal (lundi)",
|
||
weekdayCaseCapitalize: "Majuscule (Lundi)",
|
||
weekdayCaseUppercase: "Majuscules (LUNDI)",
|
||
styling: "Style",
|
||
motivation: "Motivation",
|
||
about: "À propos",
|
||
weekStartLabel: "La semaine commence le",
|
||
startViewLabel: "Vue commence par",
|
||
monday: "Lundi",
|
||
sunday: "Dimanche",
|
||
today: "Aujourd'hui",
|
||
yesterday: "Hier",
|
||
accountId: "ID du compte",
|
||
accountIdDesc: "Votre identifiant de compte unique",
|
||
accountNumberLabel: "Numéro de compte",
|
||
accountNumberDesc: "Votre numéro de compte pour identification",
|
||
connectOutlook: "Connecter Outlook",
|
||
syncTasks: "Synchroniser les tâches",
|
||
syncTasksDesc: "Synchronisez les tâches avec Google Tasks ou Microsoft To-Do.",
|
||
unsyncConfirmMsg: "Arrêter la synchronisation de \"{title}\" ? Ses tâches seront mises à la corbeille.",
|
||
unsyncConfirm: "Arrêter la sync",
|
||
unsyncCancel: "Annuler",
|
||
syncAll: "Tout synchroniser",
|
||
unsyncAll: "Tout désynchroniser",
|
||
fetchingLists: "(chargement des listes...)",
|
||
listHeader: "Liste",
|
||
syncHeader: "Sync",
|
||
noTaskListsFound: "Aucune liste de tâches trouvée.",
|
||
connectProviderAbove: "Connectez un fournisseur ci-dessus pour synchroniser les listes.",
|
||
noCalendarsFound: "Aucun calendrier trouvé ou accès refusé.",
|
||
noCalendarsApple: "Aucun calendrier chargé. Veuillez déconnecter et reconnecter.",
|
||
noCalendarsSynology: "Aucun calendrier chargé. Veuillez déconnecter et reconnecter.",
|
||
selectionAfterConnect: "Sélection disponible après connexion.",
|
||
sharedCalendar: "Calendrier partagé",
|
||
primaryCalendar: "(Principal)",
|
||
fontCustomization: "Personnalisation des polices",
|
||
dateLayoutRight: "Date à droite du jour",
|
||
dateLayoutLeft: "Date à gauche du jour",
|
||
dateLayoutAbove: "Date au-dessus du jour",
|
||
dateLayoutBelow: "Date en dessous du jour",
|
||
dateLayoutHidden: "Date masquée",
|
||
dateLayoutMobile: "Disposition date (mobile)",
|
||
dayWeekdayGap: "Espacement jour / semaine",
|
||
weekdayFont: "Police du jour",
|
||
dateFont: "Police de la date",
|
||
taskFont: "Police des tâches",
|
||
eventFont: "Police des événements",
|
||
goalFont: "Police objectif / citation",
|
||
cwFont: "Police semaine calendaire",
|
||
yearFont: "Police de l'année",
|
||
fontPlaceholder: "ex. Poppins, Bebas Neue...",
|
||
fontSizePlaceholder: "Taille (ex. 1.25rem)",
|
||
weightLight: "Léger",
|
||
weightNormal: "Normal",
|
||
weightMedium: "Moyen",
|
||
weightSemi: "Semi-gras",
|
||
weightBold: "Gras",
|
||
weightBlack: "Noir",
|
||
hourLabelFormat: "Format des heures",
|
||
hourLabelShort: "Court (8, 9, 10)",
|
||
hourLabelFull: "Complet (8:00, 9:00, 10:00)",
|
||
showSubhourLabels: "Afficher les quarts d'heure (:15, :30, :45)",
|
||
showScheduleCalendar: "Afficher le calendrier",
|
||
showDoThisNow: '"Faire maintenant" au lieu de la devise',
|
||
focusTimer: "Minuteur Focus (min)",
|
||
focusBreak: "Pause Focus (min)",
|
||
goalScopeTitle: "Période de l'objectif",
|
||
goalFallbackTitle: "Fallback objectif",
|
||
motivationalQuote: "Citation motivante / info jour férié",
|
||
nextTodo: "Prochaine tâche",
|
||
defaultText: "Texte par défaut",
|
||
apiDataSources: "Sources de données API (URLs)",
|
||
addSource: "Ajouter une source",
|
||
urlFormatHelp: "URL retournant des citations JSON",
|
||
quoteLanguages: "Langues des citations",
|
||
quoteLanguagesDesc: "Choisissez les langues de vos citations. Au moins une doit être sélectionnée.",
|
||
quoteFallbackDesc: "Si aucune source externe ne répond, des citations locales dans votre langue sont utilisées.",
|
||
defaultGoalPlaceholder: "Entrez votre objectif ici...",
|
||
saturdayColor: "Samedi",
|
||
sundayColor: "Dimanche",
|
||
todayHighlight: "Surbrillance aujourd'hui",
|
||
pastDayColor: "Jours passés",
|
||
deleteProjectConfirm: "Supprimer le projet",
|
||
importConfirmReplace: "Cela supprimera TOUTES les tâches, listes et projets existants. Continuer ?",
|
||
importSuccess: "Import terminé",
|
||
importInvalidJson: "Fichier JSON invalide",
|
||
},
|
||
es: {
|
||
settings: "Ajustes",
|
||
general: "General",
|
||
calendar: "Conexiones",
|
||
localisation: "Localización",
|
||
account: "Cuenta",
|
||
runningList: "Lista continua (pasar tareas a hoy)",
|
||
protectEventTimes: "Proteger horarios de eventos",
|
||
showTimeGrid: "Mostrar cuadrícula horaria",
|
||
timeSlotDuration: "Duración de los intervalos",
|
||
viewStyle: "Estilo de vista",
|
||
simpleView: "Simple",
|
||
calendarView: "Calendario",
|
||
listView: "Lista",
|
||
kanbanView: "Kanban",
|
||
kanbanStages: "Etapas Kanban",
|
||
kanbanStagesDesc: "Define las etapas de tu tablero Kanban. Arrastra tareas entre columnas para cambiar su etapa.",
|
||
addStage: "Añadir etapa",
|
||
stageName: "Nombre de etapa",
|
||
noStage: "Sin etapa",
|
||
language: "Idioma",
|
||
dateFormat: "Formato de fecha",
|
||
timeFormat: "Formato de hora",
|
||
saveChanges: "Guardar cambios",
|
||
connectedCalendars: "Calendarios conectados",
|
||
connectMore: "Conectar más",
|
||
connectGoogle: "Conectar Google Calendar",
|
||
connectApple: "Conectar calendario de Apple",
|
||
connectSynology: "Conectar Synology",
|
||
noCalendars: "No hay calendarios conectados.",
|
||
dataPrivacy: "Datos y privacidad",
|
||
downloadData: "Descargar mis datos",
|
||
deleteAccount: "Eliminar cuenta",
|
||
name: "Nombre",
|
||
email: "Correo electrónico",
|
||
timezone: "Zona horaria",
|
||
changePassword: "Cambiar contraseña",
|
||
newPassword: "Nueva contraseña",
|
||
confirmPassword: "Confirmar contraseña",
|
||
someday: "ALGÚN DÍA",
|
||
lists: "Listas",
|
||
newList: "Nueva lista",
|
||
allTabs: "Todas",
|
||
newTab: "Nueva pestaña",
|
||
newTabName: "Nombre de nueva pestaña:",
|
||
assignTab: "Asignar a pestaña",
|
||
noTab: "Sin pestaña",
|
||
renameTab: "Doble clic para renombrar",
|
||
dissolveTab: "Eliminar pestaña (mantener listas)",
|
||
loading: "Cargando tus tareas…",
|
||
sycing: "Sincronizando…",
|
||
synced: "Sincronizado",
|
||
localization: "Localización",
|
||
allDayEvents: "EVENTOS DE TODO EL DÍA",
|
||
syncCalendar: "Sincronizar calendario",
|
||
toggleDarkMode: "Alternar modo oscuro",
|
||
signOut: "Cerrar sesión",
|
||
startHour: "Inicio del día",
|
||
endHour: "Fin del día",
|
||
weekAbbr: "S",
|
||
goalOfWeek: "Objetivo de la semana",
|
||
goalScope: "Alcance del objetivo",
|
||
goalScopeWeek: "Por semana",
|
||
goalScopeDay: "Por día",
|
||
goalFallback: "Tipo de objetivo por defecto",
|
||
defaultGoal: "Objetivo predeterminado personalizado",
|
||
showTaskCheckboxes: "Mostrar casillas en las tareas",
|
||
showSomeday: "Mostrar sección Algún día",
|
||
showAllDay: "Mostrar sección Todo el día",
|
||
allDayPosition: "Posición de eventos de todo el día",
|
||
allDayAbove: "Arriba",
|
||
allDayBelow: "Abajo",
|
||
newPasswordDesc: "Dejar en blanco para conservar la contraseña actual.",
|
||
dateAlignment: "Alineación de la fecha",
|
||
dateVerticalAlign: "Alineación vertical de la fecha",
|
||
alignTop: "Arriba",
|
||
alignMiddle: "Centro",
|
||
alignBottom: "Abajo",
|
||
dateLayout: "Disposición de la fecha",
|
||
alignmentLeft: "Izquierda",
|
||
alignmentCenter: "Centro",
|
||
alignmentRight: "Derecha",
|
||
alignmentTight: "Compacto",
|
||
backupRestore: "Copia de seguridad y restauración",
|
||
backupRestoreDesc: "Exporta todas tus tareas, listas y proyectos como archivo JSON. Puedes editar el archivo y volver a importarlo.",
|
||
exportAllData: "Exportar todos los datos (JSON)",
|
||
importData: "Importar datos",
|
||
importMode: "Modo de importación",
|
||
importModeMerge: "Combinar",
|
||
importModeMergeDesc: "Añadir los datos importados junto a las tareas existentes",
|
||
importModeReplace: "Reemplazar",
|
||
importModeReplaceDesc: "Eliminar todos los datos existentes y reemplazarlos con los datos importados",
|
||
importReplaceWarning: "Advertencia: ¡Se eliminarán permanentemente todas tus tareas, listas y proyectos actuales!",
|
||
importSelectFile: "Seleccionar archivo JSON…",
|
||
importButton: "Importar",
|
||
importing: "Importando…",
|
||
exporting: "Exportando…",
|
||
projects: "Proyectos",
|
||
projectsDesc: "Organiza las tareas con proyectos de colores",
|
||
addProject: "Añadir proyecto",
|
||
projectName: "Nombre",
|
||
projectColor: "Color",
|
||
noProjects: "Aún no hay proyectos",
|
||
assignProject: "Asignar proyecto",
|
||
removeProject: "Quitar proyecto",
|
||
weekdayFormat: "Formato de los días",
|
||
weekdayFormatFull: "Nombre completo (lunes)",
|
||
weekdayFormatShort: "Abreviado (lun.)",
|
||
weekdayFormatNarrow: "Estrecho (L)",
|
||
weekdayFormatCustom: "Personalizado",
|
||
customWeekdayNamesMon: "Lu; Ma; Mi; Ju; Vi; Sá; Do",
|
||
customWeekdayNamesSun: "Do; Lu; Ma; Mi; Ju; Vi; Sá",
|
||
weekdayCase: "Mayúsculas de los días",
|
||
weekdayCaseNormal: "Normal (lunes)",
|
||
weekdayCaseCapitalize: "Mayúscula inicial (Lunes)",
|
||
weekdayCaseUppercase: "Mayúsculas (LUNES)",
|
||
styling: "Estilo",
|
||
motivation: "Motivación",
|
||
about: "Acerca de",
|
||
weekStartLabel: "La semana empieza el",
|
||
startViewLabel: "Vista empieza con",
|
||
monday: "Lunes",
|
||
sunday: "Domingo",
|
||
today: "Hoy",
|
||
yesterday: "Ayer",
|
||
accountId: "ID de cuenta",
|
||
accountIdDesc: "Tu identificador único de cuenta",
|
||
accountNumberLabel: "Número de cuenta",
|
||
accountNumberDesc: "Tu número de cuenta para identificación",
|
||
connectOutlook: "Conectar Outlook",
|
||
syncTasks: "Sincronizar tareas",
|
||
syncTasksDesc: "Sincroniza tareas con Google Tasks o Microsoft To-Do.",
|
||
unsyncConfirmMsg: "¿Dejar de sincronizar \"{title}\"? Sus tareas se moverán a la papelera.",
|
||
unsyncConfirm: "Dejar de sincronizar",
|
||
unsyncCancel: "Cancelar",
|
||
syncAll: "Sincronizar todo",
|
||
unsyncAll: "Desincronizar todo",
|
||
fetchingLists: "(cargando listas...)",
|
||
listHeader: "Lista",
|
||
syncHeader: "Sync",
|
||
noTaskListsFound: "No se encontraron listas de tareas.",
|
||
connectProviderAbove: "Conecta un proveedor arriba para sincronizar listas.",
|
||
noCalendarsFound: "No se encontraron calendarios o acceso denegado.",
|
||
noCalendarsApple: "No hay calendarios cargados. Desconecta y reconecta.",
|
||
noCalendarsSynology: "No hay calendarios cargados. Desconecta y reconecta.",
|
||
selectionAfterConnect: "Selección disponible tras conectar.",
|
||
sharedCalendar: "Calendario compartido",
|
||
primaryCalendar: "(Principal)",
|
||
fontCustomization: "Personalización de fuentes",
|
||
dateLayoutRight: "Fecha a la derecha del día",
|
||
dateLayoutLeft: "Fecha a la izquierda del día",
|
||
dateLayoutAbove: "Fecha encima del día",
|
||
dateLayoutBelow: "Fecha debajo del día",
|
||
dateLayoutHidden: "Fecha oculta",
|
||
dateLayoutMobile: "Disposición fecha (móvil)",
|
||
dayWeekdayGap: "Espacio día / semana",
|
||
weekdayFont: "Fuente del día",
|
||
dateFont: "Fuente de la fecha",
|
||
taskFont: "Fuente de tareas",
|
||
eventFont: "Fuente de eventos",
|
||
goalFont: "Fuente objetivo / cita",
|
||
cwFont: "Fuente semana calendario",
|
||
yearFont: "Fuente del año",
|
||
fontPlaceholder: "ej. Poppins, Bebas Neue...",
|
||
fontSizePlaceholder: "Tamaño (ej. 1.25rem)",
|
||
weightLight: "Ligero",
|
||
weightNormal: "Normal",
|
||
weightMedium: "Medio",
|
||
weightSemi: "Semi-negrita",
|
||
weightBold: "Negrita",
|
||
weightBlack: "Negro",
|
||
hourLabelFormat: "Formato de horas",
|
||
hourLabelShort: "Corto (8, 9, 10)",
|
||
hourLabelFull: "Completo (8:00, 9:00, 10:00)",
|
||
showSubhourLabels: "Mostrar cuartos de hora (:15, :30, :45)",
|
||
showScheduleCalendar: "Mostrar calendario",
|
||
showDoThisNow: '"Hacer ahora" en vez de lema',
|
||
focusTimer: "Temporizador Focus (min)",
|
||
focusBreak: "Pausa Focus (min)",
|
||
goalScopeTitle: "Periodo del objetivo",
|
||
goalFallbackTitle: "Fallback del objetivo",
|
||
motivationalQuote: "Cita motivacional / festivo",
|
||
nextTodo: "Siguiente tarea",
|
||
defaultText: "Texto predeterminado",
|
||
apiDataSources: "Fuentes de datos API (URLs)",
|
||
addSource: "Añadir fuente",
|
||
urlFormatHelp: "URL que devuelve citas JSON",
|
||
quoteLanguages: "Idiomas de citas",
|
||
quoteLanguagesDesc: "Elige los idiomas de tus citas. Al menos uno debe estar seleccionado.",
|
||
quoteFallbackDesc: "Si ninguna fuente externa responde, se usan citas locales en tu idioma.",
|
||
defaultGoalPlaceholder: "Ingresa tu objetivo aquí...",
|
||
saturdayColor: "Sábado",
|
||
sundayColor: "Domingo",
|
||
todayHighlight: "Resaltado de hoy",
|
||
pastDayColor: "Días pasados",
|
||
deleteProjectConfirm: "Eliminar proyecto",
|
||
importConfirmReplace: "Esto eliminará TODAS las tareas, listas y proyectos existentes. ¿Continuar?",
|
||
importSuccess: "Importación completada",
|
||
importInvalidJson: "Archivo JSON inválido",
|
||
},
|
||
it: {
|
||
settings: "Impostazioni",
|
||
general: "Generali",
|
||
calendar: "Connessioni",
|
||
localisation: "Localizzazione",
|
||
account: "Account",
|
||
runningList: "Lista continua (sposta le attività a oggi)",
|
||
protectEventTimes: "Proteggi gli orari degli eventi",
|
||
showTimeGrid: "Mostra griglia oraria",
|
||
timeSlotDuration: "Durata degli intervalli",
|
||
viewStyle: "Stile di visualizzazione",
|
||
simpleView: "Semplice",
|
||
calendarView: "Calendario",
|
||
listView: "Lista",
|
||
kanbanView: "Kanban",
|
||
kanbanStages: "Fasi Kanban",
|
||
kanbanStagesDesc: "Definisci le fasi della tua board Kanban. Trascina le attività tra le colonne per cambiare la loro fase.",
|
||
addStage: "Aggiungi fase",
|
||
stageName: "Nome fase",
|
||
noStage: "Nessuna fase",
|
||
language: "Lingua",
|
||
dateFormat: "Formato data",
|
||
timeFormat: "Formato ora",
|
||
saveChanges: "Salva modifiche",
|
||
connectedCalendars: "Calendari collegati",
|
||
connectMore: "Collega altri",
|
||
connectGoogle: "Collega Google Calendar",
|
||
connectApple: "Collega il calendario Apple",
|
||
connectSynology: "Collega Synology",
|
||
noCalendars: "Nessun calendario collegato.",
|
||
dataPrivacy: "Dati e privacy",
|
||
downloadData: "Scarica i miei dati",
|
||
deleteAccount: "Elimina account",
|
||
name: "Nome",
|
||
email: "E-mail",
|
||
timezone: "Fuso orario",
|
||
changePassword: "Cambia password",
|
||
newPassword: "Nuova password",
|
||
confirmPassword: "Conferma password",
|
||
someday: "UN GIORNO",
|
||
lists: "Liste",
|
||
newList: "Nuova lista",
|
||
allTabs: "Tutte",
|
||
newTab: "Nuova scheda",
|
||
newTabName: "Nome nuova scheda:",
|
||
assignTab: "Assegna a scheda",
|
||
noTab: "Nessuna scheda",
|
||
renameTab: "Doppio clic per rinominare",
|
||
dissolveTab: "Rimuovi scheda (mantieni liste)",
|
||
loading: "Caricamento delle attività…",
|
||
sycing: "Sincronizzazione…",
|
||
synced: "Sincronizzato",
|
||
localization: "Localizzazione",
|
||
allDayEvents: "EVENTI GIORNATA INTERA",
|
||
syncCalendar: "Sincronizza calendario",
|
||
toggleDarkMode: "Attiva/disattiva modalità scura",
|
||
signOut: "Esci",
|
||
startHour: "Inizio giornata",
|
||
endHour: "Fine giornata",
|
||
weekAbbr: "S",
|
||
goalOfWeek: "Obiettivo della settimana",
|
||
goalScope: "Ambito dell'obiettivo",
|
||
goalScopeWeek: "Per settimana",
|
||
goalScopeDay: "Per giorno",
|
||
goalFallback: "Tipo di obiettivo predefinito",
|
||
defaultGoal: "Obiettivo predefinito personalizzato",
|
||
showTaskCheckboxes: "Mostra caselle di spunta sulle attività",
|
||
showSomeday: "Mostra sezione Un giorno",
|
||
showAllDay: "Mostra sezione Giornata intera",
|
||
allDayPosition: "Posizione eventi giornata intera",
|
||
allDayAbove: "Sopra",
|
||
allDayBelow: "Sotto",
|
||
newPasswordDesc: "Lascia vuoto per mantenere la password attuale.",
|
||
dateAlignment: "Allineamento della data",
|
||
dateVerticalAlign: "Allineamento verticale della data",
|
||
alignTop: "In alto",
|
||
alignMiddle: "Al centro",
|
||
alignBottom: "In basso",
|
||
dateLayout: "Disposizione della data",
|
||
alignmentLeft: "Sinistra",
|
||
alignmentCenter: "Centro",
|
||
alignmentRight: "Destra",
|
||
alignmentTight: "Compatto",
|
||
backupRestore: "Backup e ripristino",
|
||
backupRestoreDesc: "Esporta tutte le attività, le liste e i progetti come file JSON. Puoi modificare il file e reimportarlo.",
|
||
exportAllData: "Esporta tutti i dati (JSON)",
|
||
importData: "Importa dati",
|
||
importMode: "Modalità di importazione",
|
||
importModeMerge: "Unisci",
|
||
importModeMergeDesc: "Aggiungere i dati importati alle attività esistenti",
|
||
importModeReplace: "Sostituisci",
|
||
importModeReplaceDesc: "Elimina tutti i dati esistenti e sostituiscili con i dati importati",
|
||
importReplaceWarning: "Attenzione: tutte le attività, le liste e i progetti attuali verranno eliminati definitivamente!",
|
||
importSelectFile: "Seleziona file JSON…",
|
||
importButton: "Importa",
|
||
importing: "Importazione…",
|
||
exporting: "Esportazione…",
|
||
projects: "Progetti",
|
||
projectsDesc: "Organizza le attività con progetti colorati",
|
||
addProject: "Aggiungi progetto",
|
||
projectName: "Nome",
|
||
projectColor: "Colore",
|
||
noProjects: "Nessun progetto",
|
||
assignProject: "Assegna progetto",
|
||
removeProject: "Rimuovi progetto",
|
||
weekdayFormat: "Formato dei giorni",
|
||
weekdayFormatFull: "Nome completo (lunedì)",
|
||
weekdayFormatShort: "Abbreviato (lun)",
|
||
weekdayFormatNarrow: "Stretto (L)",
|
||
weekdayFormatCustom: "Personalizzato",
|
||
customWeekdayNamesMon: "Lu; Ma; Me; Gi; Ve; Sa; Do",
|
||
customWeekdayNamesSun: "Do; Lu; Ma; Me; Gi; Ve; Sa",
|
||
weekdayCase: "Maiuscole dei giorni",
|
||
weekdayCaseNormal: "Normale (lunedì)",
|
||
weekdayCaseCapitalize: "Iniziale maiuscola (Lunedì)",
|
||
weekdayCaseUppercase: "Maiuscolo (LUNEDÌ)",
|
||
styling: "Stile",
|
||
motivation: "Motivazione",
|
||
about: "Info",
|
||
weekStartLabel: "La settimana inizia il",
|
||
startViewLabel: "Vista inizia con",
|
||
monday: "Lunedì",
|
||
sunday: "Domenica",
|
||
today: "Oggi",
|
||
yesterday: "Ieri",
|
||
accountId: "ID account",
|
||
accountIdDesc: "Il tuo identificatore account unico",
|
||
accountNumberLabel: "Numero account",
|
||
accountNumberDesc: "Il tuo numero account per identificazione",
|
||
connectOutlook: "Connetti Outlook",
|
||
syncTasks: "Sincronizza attività",
|
||
syncTasksDesc: "Sincronizza le attività con Google Tasks o Microsoft To-Do.",
|
||
unsyncConfirmMsg: "Interrompere la sincronizzazione di \"{title}\"? Le attività verranno spostate nel cestino.",
|
||
unsyncConfirm: "Interrompi sync",
|
||
unsyncCancel: "Annulla",
|
||
syncAll: "Sincronizza tutto",
|
||
unsyncAll: "Desincronizza tutto",
|
||
fetchingLists: "(caricamento liste...)",
|
||
listHeader: "Lista",
|
||
syncHeader: "Sync",
|
||
noTaskListsFound: "Nessuna lista di attività trovata.",
|
||
connectProviderAbove: "Connetti un provider sopra per sincronizzare le liste.",
|
||
noCalendarsFound: "Nessun calendario trovato o accesso negato.",
|
||
noCalendarsApple: "Nessun calendario caricato. Disconnetti e riconnetti.",
|
||
noCalendarsSynology: "Nessun calendario caricato. Disconnetti e riconnetti.",
|
||
selectionAfterConnect: "Selezione disponibile dopo la connessione.",
|
||
sharedCalendar: "Calendario condiviso",
|
||
primaryCalendar: "(Principale)",
|
||
fontCustomization: "Personalizzazione caratteri",
|
||
dateLayoutRight: "Data a destra del giorno",
|
||
dateLayoutLeft: "Data a sinistra del giorno",
|
||
dateLayoutAbove: "Data sopra il giorno",
|
||
dateLayoutBelow: "Data sotto il giorno",
|
||
dateLayoutHidden: "Data nascosta",
|
||
dateLayoutMobile: "Layout data (mobile)",
|
||
dayWeekdayGap: "Spazio giorno / settimana",
|
||
weekdayFont: "Carattere giorno",
|
||
dateFont: "Carattere data",
|
||
taskFont: "Carattere attività",
|
||
eventFont: "Carattere eventi",
|
||
goalFont: "Carattere obiettivo / citazione",
|
||
cwFont: "Carattere settimana calendario",
|
||
yearFont: "Carattere anno",
|
||
fontPlaceholder: "es. Poppins, Bebas Neue...",
|
||
fontSizePlaceholder: "Dimensione (es. 1.25rem)",
|
||
weightLight: "Leggero",
|
||
weightNormal: "Normale",
|
||
weightMedium: "Medio",
|
||
weightSemi: "Semi-grassetto",
|
||
weightBold: "Grassetto",
|
||
weightBlack: "Nero",
|
||
hourLabelFormat: "Formato delle ore",
|
||
hourLabelShort: "Breve (8, 9, 10)",
|
||
hourLabelFull: "Completo (8:00, 9:00, 10:00)",
|
||
showSubhourLabels: "Mostra quarti d'ora (:15, :30, :45)",
|
||
showScheduleCalendar: "Mostra calendario",
|
||
showDoThisNow: '"Fai ora" invece del motto',
|
||
focusTimer: "Timer Focus (min)",
|
||
focusBreak: "Pausa Focus (min)",
|
||
goalScopeTitle: "Periodo dell'obiettivo",
|
||
goalFallbackTitle: "Fallback obiettivo",
|
||
motivationalQuote: "Citazione motivazionale / festività",
|
||
nextTodo: "Prossima attività",
|
||
defaultText: "Testo predefinito",
|
||
apiDataSources: "Fonti dati API (URL)",
|
||
addSource: "Aggiungi fonte",
|
||
urlFormatHelp: "URL che restituisce citazioni JSON",
|
||
quoteLanguages: "Lingue delle citazioni",
|
||
quoteLanguagesDesc: "Scegli le lingue delle citazioni. Almeno una deve essere selezionata.",
|
||
quoteFallbackDesc: "Se nessuna fonte esterna risponde, vengono usate citazioni locali nella tua lingua.",
|
||
defaultGoalPlaceholder: "Inserisci il tuo obiettivo qui...",
|
||
saturdayColor: "Sabato",
|
||
sundayColor: "Domenica",
|
||
todayHighlight: "Evidenziazione oggi",
|
||
pastDayColor: "Giorni passati",
|
||
deleteProjectConfirm: "Elimina progetto",
|
||
importConfirmReplace: "Questo eliminerà TUTTE le attività, liste e progetti esistenti. Continuare?",
|
||
importSuccess: "Importazione completata",
|
||
importInvalidJson: "File JSON non valido",
|
||
},
|
||
};
|
||
|
||
// Date utilities
|
||
function getStartOfWeek(date: Date, startDay: number = 0): Date {
|
||
const d = new Date(date);
|
||
const day = d.getDay();
|
||
const diff = d.getDate() - day + (day < startDay ? -7 : 0) + startDay; // if today is sun(0) and start is mon(1), day < start (0 < 1) -> -7 + 1 = -6. 0 - 6 = -6. Correct.
|
||
// Wait, let's re-verify:
|
||
// Start Mon(1). Today Sun(0). day=0. diff = date - 0 + (-7) + 1 = date - 6. Correct (last Monday).
|
||
// Start Mon(1). Today Mon(1). day=1. diff = date - 1 + (0) + 1 = date. Correct.
|
||
// Start Sun(0). Today Mon(1). day=1. diff = date - 1 + (0) + 0 = date - 1. Correct (last Sunday).
|
||
// Start Sun(0). Today Sun(0). day=0. diff = date - 0 + (0) + 0 = date. Correct.
|
||
// What if Start Mon(1), Today Tue(2). day=2. diff = date - 2 + 0 + 1 = date - 1. Correct.
|
||
|
||
// Better logic:
|
||
// const day = d.getDay();
|
||
// const diff = (day < startDay ? 7 : 0) + day - startDay;
|
||
// d.setDate(d.getDate() - diff);
|
||
//
|
||
// Let's stick to a robust one:
|
||
const currentDay = d.getDay();
|
||
const distance = (currentDay - startDay + 7) % 7;
|
||
d.setDate(d.getDate() - distance);
|
||
return d;
|
||
}
|
||
|
||
function formatDateHeader(date: Date, locale: string = "en-US"): string {
|
||
return date.toLocaleDateString(locale, { day: "numeric", month: "short" }); // e.g. 12. Feb.
|
||
}
|
||
|
||
function getDayName(date: Date, locale: string = "en-US", format?: string, customNames?: string, weekStartDay: number = 0, dayCase: string = "capitalize"): string {
|
||
let name: string;
|
||
if (format === "custom" && customNames) {
|
||
// Split by comma or semicolon to allow spaces in names
|
||
const names = customNames.split(/[,;]+/).map(s => s.trim()).filter(Boolean);
|
||
if (names.length === 7) {
|
||
// Adjust index based on weekStartDay (0=Sun, 1=Mon)
|
||
const index = (date.getDay() - weekStartDay + 7) % 7;
|
||
name = names[index];
|
||
} else {
|
||
name = date.toLocaleDateString(locale, { weekday: "long" });
|
||
}
|
||
} else {
|
||
const weekdayOption = format === "narrow" ? "narrow" : (format === "short" ? "short" : "long");
|
||
try {
|
||
name = date.toLocaleDateString(locale, { weekday: weekdayOption });
|
||
} catch (e) {
|
||
name = date.toLocaleDateString("en-US", { weekday: weekdayOption });
|
||
}
|
||
}
|
||
if (dayCase === "uppercase") return name.toUpperCase();
|
||
if (dayCase === "normal") return name.toLowerCase();
|
||
// capitalize: first letter uppercase, rest lowercase
|
||
return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
|
||
}
|
||
|
||
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, format: "short" | "full" = "short", timeFormat: string = "24h"): string {
|
||
if (timeFormat === "12h") {
|
||
const h = hour % 12 || 12;
|
||
const ampm = hour >= 12 ? "PM" : "AM";
|
||
return format === "full" ? `${h}:00 ${ampm}` : `${h} ${ampm}`;
|
||
}
|
||
return format === "full" ? `${hour}:00` : `${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 {
|
||
// ISO 8601 week number: weeks start on Monday
|
||
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);
|
||
}
|
||
|
||
// Get the Monday of the ISO week that the first visible day belongs to.
|
||
// This ensures CW changes when the first visible day crosses into a new ISO week.
|
||
function getCWReferenceDate(days: Date[]): Date {
|
||
if (days.length === 0) return new Date();
|
||
const first = days[0];
|
||
const dow = first.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
|
||
// Calculate distance back to Monday (ISO week start)
|
||
// Sunday (0) → go back 6 days to previous Monday
|
||
// Monday (1) → 0, Tuesday (2) → 1, etc.
|
||
const distToMonday = dow === 0 ? 6 : dow - 1;
|
||
return new Date(first.getTime() - distToMonday * 86400000);
|
||
}
|
||
|
||
// 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);
|
||
};
|
||
|
||
// Helper to invert colors for dark mode
|
||
function invertColor(hex: string): string {
|
||
if (!hex) return hex;
|
||
let color = hex.startsWith("#") ? hex.slice(1) : hex;
|
||
if (color.length === 3) {
|
||
color = color
|
||
.split("")
|
||
.map((c) => c + c)
|
||
.join("");
|
||
}
|
||
if (color.length !== 6) return hex;
|
||
|
||
try {
|
||
const r = (255 - parseInt(color.slice(0, 2), 16))
|
||
.toString(16)
|
||
.padStart(2, "0");
|
||
const g = (255 - parseInt(color.slice(2, 4), 16))
|
||
.toString(16)
|
||
.padStart(2, "0");
|
||
const b = (255 - parseInt(color.slice(4, 6), 16))
|
||
.toString(16)
|
||
.padStart(2, "0");
|
||
return `#${r}${g}${b}`;
|
||
} catch (e) {
|
||
return hex;
|
||
}
|
||
}
|
||
|
||
// Helper to lighten color for dark mode
|
||
function adjustColorForDarkMode(hex: string, isDarkMode: boolean): string {
|
||
if (!isDarkMode || !hex || !hex.startsWith("#")) return hex;
|
||
|
||
// Simple hex to RGB
|
||
let r = parseInt(hex.slice(1, 3), 16);
|
||
let g = parseInt(hex.slice(3, 5), 16);
|
||
let b = parseInt(hex.slice(5, 7), 16);
|
||
|
||
// Calculate brightness (0-255)
|
||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||
|
||
// If it's too dark for dark mode, lighten it
|
||
if (brightness < 120) {
|
||
r = Math.min(255, r + 100);
|
||
g = Math.min(255, g + 100);
|
||
b = Math.min(255, b + 100);
|
||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||
}
|
||
|
||
return hex;
|
||
}
|
||
|
||
// 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(() => {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
return d;
|
||
});
|
||
const [viewDays, setViewDays] = useState(7);
|
||
const savedViewDaysRef = useRef(7); // Track user's saved preference for restoring on resize
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
// Responsive: auto-adjust viewDays based on screen width
|
||
useEffect(() => {
|
||
const getResponsiveViewDays = (width: number): number => {
|
||
if (width <= 480) return 1;
|
||
if (width <= 768) return 3;
|
||
if (width <= 1024) return Math.min(savedViewDaysRef.current, 5);
|
||
return savedViewDaysRef.current;
|
||
};
|
||
|
||
const handleResize = () => {
|
||
const responsiveDays = getResponsiveViewDays(window.innerWidth);
|
||
setViewDays(responsiveDays);
|
||
};
|
||
|
||
// Set initial value
|
||
handleResize();
|
||
|
||
window.addEventListener('resize', handleResize);
|
||
return () => window.removeEventListener('resize', handleResize);
|
||
}, []); // savedViewDaysRef is a ref, so no dependency needed
|
||
const [isSyncing, setIsSyncing] = useState(false);
|
||
const [isFetchingCalendar, setIsFetchingCalendar] = useState(false);
|
||
const [syncError, setSyncError] = useState<string | null>(null);
|
||
const syncCountRef = useRef(0);
|
||
const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []);
|
||
const endSync = useCallback(() => { syncCountRef.current = Math.max(0, syncCountRef.current - 1); if (syncCountRef.current === 0) setIsSyncing(false); }, []);
|
||
const [darkMode, setDarkMode] = useState(false);
|
||
const [timeFormat, setTimeFormat] = useState("24h");
|
||
const [dateFormat, setDateFormat] = useState("yyyy-MM-dd");
|
||
const [hourLabelFormat, setHourLabelFormat] = useState<"short" | "full">("short");
|
||
const [showSubHourSlots, setShowSubHourSlots] = useState(true);
|
||
const [allDayPosition, setAllDayPosition] = useState<"above" | "below">("below");
|
||
|
||
const [somedayExpanded, setSomedayExpanded] = useState(true);
|
||
const [isAllDayExpanded, setIsAllDayExpanded] = useState(true);
|
||
const [somedayLists, setSomedayLists] = useState<SomedayList[]>([]);
|
||
const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]);
|
||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||
const [draggingListId, setDraggingListId] = useState<string | null>(null);
|
||
const [listToDelete, setListToDelete] = useState<string | null>(null);
|
||
const [activeSomedayTab, setActiveSomedayTab] = useState<string | null>(null);
|
||
const [editingTabName, setEditingTabName] = useState<string | null>(null);
|
||
const [renamingTabValue, setRenamingTabValue] = useState("");
|
||
const [newTabForListId, setNewTabForListId] = useState<string | null>(null);
|
||
const [newTabNameValue, setNewTabNameValue] = useState("");
|
||
const [creatingNewTab, setCreatingNewTab] = useState(false);
|
||
const [creatingNewTabName, setCreatingNewTabName] = useState("");
|
||
const [dragOverTab, setDragOverTab] = useState<string | null>(null);
|
||
const [customTabs, setCustomTabs] = useState<string[]>([]);
|
||
|
||
useEffect(() => {
|
||
if (typeof window !== "undefined") {
|
||
const saved = localStorage.getItem("weekly_active_someday_tab");
|
||
if (saved) setActiveSomedayTab(saved === "__all__" ? null : saved);
|
||
try {
|
||
const savedTabs = localStorage.getItem("weekly_custom_tabs");
|
||
if (savedTabs) setCustomTabs(JSON.parse(savedTabs));
|
||
} catch { /* ignore */ }
|
||
}
|
||
}, []);
|
||
|
||
const saveCustomTabs = (tabs: string[]) => {
|
||
setCustomTabs(tabs);
|
||
localStorage.setItem("weekly_custom_tabs", JSON.stringify(tabs));
|
||
};
|
||
|
||
const somedayTabs = useMemo(() => {
|
||
const tabs = new Set<string>();
|
||
somedayLists.forEach(l => { if (l.tab) tabs.add(l.tab); });
|
||
customTabs.forEach(t => tabs.add(t));
|
||
return Array.from(tabs).sort();
|
||
}, [somedayLists, customTabs]);
|
||
|
||
const setSomedayTab = (tab: string | null) => {
|
||
setActiveSomedayTab(tab);
|
||
localStorage.setItem("weekly_active_someday_tab", tab ?? "__all__");
|
||
};
|
||
|
||
const assignListToTab = async (listId: string, tab: string | null) => {
|
||
setSomedayLists(prev => prev.map(l => l.id === listId ? { ...l, tab } : l));
|
||
try {
|
||
await fetch("/api/someday-lists", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: listId, tab }),
|
||
});
|
||
} catch (e) {
|
||
console.error("Failed to update list tab:", e);
|
||
}
|
||
};
|
||
|
||
const renameTab = async (oldName: string, newName: string) => {
|
||
if (!newName.trim() || newName === oldName) return;
|
||
const trimmed = newName.trim();
|
||
const listsToUpdate = somedayLists.filter(l => l.tab === oldName);
|
||
setSomedayLists(prev => prev.map(l => l.tab === oldName ? { ...l, tab: trimmed } : l));
|
||
if (customTabs.includes(oldName)) {
|
||
saveCustomTabs(customTabs.map(t => t === oldName ? trimmed : t));
|
||
}
|
||
if (activeSomedayTab === oldName) setSomedayTab(trimmed);
|
||
for (const list of listsToUpdate) {
|
||
try {
|
||
await fetch("/api/someday-lists", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: list.id, tab: trimmed }),
|
||
});
|
||
} catch (e) {
|
||
console.error("Failed to rename tab for list:", e);
|
||
}
|
||
}
|
||
};
|
||
|
||
const dissolveTab = async (tabName: string) => {
|
||
const listsToUpdate = somedayLists.filter(l => l.tab === tabName);
|
||
setSomedayLists(prev => prev.map(l => l.tab === tabName ? { ...l, tab: null } : l));
|
||
if (customTabs.includes(tabName)) {
|
||
saveCustomTabs(customTabs.filter(t => t !== tabName));
|
||
}
|
||
if (activeSomedayTab === tabName) setSomedayTab(null);
|
||
for (const list of listsToUpdate) {
|
||
try {
|
||
await fetch("/api/someday-lists", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: list.id, tab: null }),
|
||
});
|
||
} catch (e) {
|
||
console.error("Failed to dissolve tab for list:", e);
|
||
}
|
||
}
|
||
};
|
||
|
||
const filteredSomedayLists = useMemo(() => {
|
||
if (activeSomedayTab === null) return somedayLists;
|
||
return somedayLists.filter(l => (l.tab || null) === activeSomedayTab);
|
||
}, [somedayLists, activeSomedayTab]);
|
||
const [dropTargetListIndex, setDropTargetListIndex] = useState<number | null>(null);
|
||
const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null);
|
||
const isDragFromHandle = useRef(false);
|
||
|
||
// Undo/Redo state
|
||
const undoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]);
|
||
const redoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]);
|
||
const [undoCount, setUndoCount] = useState(0);
|
||
const [redoCount, setRedoCount] = useState(0);
|
||
const skipSnapshotRef = useRef(false);
|
||
|
||
// Mobile detection
|
||
const [isMobile, setIsMobile] = useState(false);
|
||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||
const [showMobileFabSheet, setShowMobileFabSheet] = useState(false);
|
||
const [fabTaskTitle, setFabTaskTitle] = useState("");
|
||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||
const fabTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
// Moved state definitions to the top
|
||
const [showSettings, setShowSettings] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<
|
||
"calendar" | "general" | "account" | "styling" | "motivation" | "about"
|
||
>("general");
|
||
const [exportStartDate, setExportStartDate] = useState("");
|
||
const [exportEndDate, setExportEndDate] = useState("");
|
||
const [passwords, setPasswords] = useState({ new: "", confirm: "" });
|
||
const [accountMsg, setAccountMsg] = useState<string>("");
|
||
const [importingTasksState, setImportingTasksState] =
|
||
useState<boolean>(false);
|
||
const [importStatusMsg, setImportStatusMsg] = useState<{
|
||
type: "success" | "error";
|
||
text: string;
|
||
} | null>(null);
|
||
const [unsyncConfirm, setUnsyncConfirm] = useState<{
|
||
provider: "google" | "apple" | "outlook" | "synology";
|
||
list: { id: string; title: string };
|
||
} | null>(null);
|
||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||
const [importProvider, setImportProvider] = useState<
|
||
"google" | "apple" | "outlook" | "synology" | null
|
||
>(null);
|
||
const [importLists, setImportLists] = useState<
|
||
{ id: string; title: string }[]
|
||
>([]);
|
||
const [isFetchingLists, setIsFetchingLists] = useState(false);
|
||
const [availableTaskLists, setAvailableTaskLists] = useState<{
|
||
[key in "google" | "apple" | "outlook" | "synology"]?: { id: string; title: string }[];
|
||
}>({});
|
||
const [isFetchingProviderLists, setIsFetchingProviderLists] = useState<
|
||
Record<string, boolean>
|
||
>({});
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
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;
|
||
focusBreakDuration?: number;
|
||
showTimeGrid?: boolean;
|
||
cellDuration?: number;
|
||
viewStyle?: string;
|
||
fontSize?: "S" | "M" | "L";
|
||
showNextTask?: boolean;
|
||
showSomeday?: boolean;
|
||
showAllDayEvents?: boolean;
|
||
showSchedule?: boolean;
|
||
headlineFont?: string;
|
||
headlineFontSize?: string;
|
||
headlineFontWeight?: string;
|
||
dateFontFamily?: string;
|
||
dateFontSize?: string;
|
||
dateFontWeight?: string;
|
||
timeTaskFontFamily?: string;
|
||
timeTaskFontSize?: string;
|
||
timeTaskFontWeight?: string;
|
||
bodyFont?: string;
|
||
taskFontFamily?: string;
|
||
taskFontSize?: string;
|
||
taskFontWeight?: string;
|
||
eventFontFamily?: string;
|
||
eventFontSize?: string;
|
||
eventFontWeight?: string;
|
||
fontWeight?: string;
|
||
weekendColorSat?: string;
|
||
weekendColorSun?: string;
|
||
weekdayColor?: string;
|
||
dateColor?: string;
|
||
taskColor?: string;
|
||
todayHighlightColor?: string;
|
||
pastDayColor?: string;
|
||
goalFallbackType?: "quote" | "next_todo" | "default";
|
||
quoteSourceUrl?: string;
|
||
goalDefaultSentence?: string;
|
||
goalFontFamily?: string;
|
||
goalFontSize?: string;
|
||
goalFontWeight?: string;
|
||
goalScope?: "week" | "day";
|
||
dateLayout?: "above" | "below" | "left" | "right" | "hidden";
|
||
mobileDateLayout?: "above" | "below" | "left" | "right" | "hidden";
|
||
dateAlignment?: "left" | "center" | "right" | "tight";
|
||
hourLabelFormat?: "short" | "full";
|
||
showSubHourSlots?: boolean;
|
||
allDayPosition?: "above" | "below";
|
||
cwFontFamily?: string;
|
||
cwFontSize?: string;
|
||
cwFontWeight?: string;
|
||
cwColor?: string;
|
||
yearFontFamily?: string;
|
||
yearFontSize?: string;
|
||
yearFontWeight?: string;
|
||
yearColor?: string;
|
||
dayHeaderGap?: string;
|
||
showTaskCheckboxes?: boolean;
|
||
quoteSourceUrls?: string[];
|
||
quoteLanguages?: string[];
|
||
startDayOffset?: number;
|
||
id?: string;
|
||
accountNumber?: number;
|
||
weekdayFormat?: "long" | "short" | "narrow" | "custom";
|
||
weekdayCase?: "normal" | "capitalize" | "uppercase";
|
||
customWeekdayNames?: string;
|
||
dateVerticalAlign?: "top" | "middle" | "bottom";
|
||
}>({
|
||
name: session?.user?.name || "",
|
||
email: session?.user?.email || "",
|
||
timezone: "UTC",
|
||
language: "de",
|
||
dateFormat: "yyyy-MM-dd",
|
||
timeFormat: "24h",
|
||
startHour: 8,
|
||
endHour: 18,
|
||
autoRolling: false,
|
||
protectEventTimes: false,
|
||
showTimeGrid: true,
|
||
cellDuration: 30,
|
||
viewStyle: "simple",
|
||
fontSize: "M",
|
||
showNextTask: false,
|
||
showSomeday: true,
|
||
showAllDayEvents: true,
|
||
showSchedule: true,
|
||
hourLabelFormat: "short",
|
||
showSubHourSlots: true,
|
||
dayHeaderGap: "0.75em",
|
||
dateVerticalAlign: "middle",
|
||
allDayPosition: "above",
|
||
focusTimerDuration: 25,
|
||
focusBreakDuration: 5,
|
||
headlineFont: "Oswald",
|
||
headlineFontSize: "1.5rem",
|
||
headlineFontWeight: "900",
|
||
weekdayColor: "#0ea5e9",
|
||
dateFontFamily: "Inter",
|
||
dateFontSize: "0.65rem",
|
||
dateFontWeight: "400",
|
||
timeTaskFontFamily: "Inter",
|
||
timeTaskFontSize: "0.75rem",
|
||
timeTaskFontWeight: "500",
|
||
bodyFont: "Inter",
|
||
taskFontFamily: "Inter",
|
||
taskFontSize: "0.9rem",
|
||
taskFontWeight: "400",
|
||
eventFontFamily: "Inter",
|
||
eventFontSize: "0.85rem",
|
||
eventFontWeight: "400",
|
||
goalFallbackType: "quote",
|
||
goalFontFamily: "Lato",
|
||
goalFontSize: "1rem",
|
||
goalFontWeight: "500",
|
||
goalScope: "week",
|
||
dateLayout: "right",
|
||
mobileDateLayout: "below",
|
||
dateAlignment: "center",
|
||
weekendColorSat: "#ffc107",
|
||
weekendColorSun: "#dc2626",
|
||
pastDayColor: "#a6a6a7",
|
||
cwFontFamily: "Oswald",
|
||
cwFontSize: "1.5rem",
|
||
cwFontWeight: "700",
|
||
yearFontFamily: "Oswald",
|
||
yearFontSize: "1.5rem",
|
||
yearFontWeight: "700",
|
||
quoteSourceUrl: "",
|
||
quoteSourceUrls: [],
|
||
quoteLanguages: ["en", "de"],
|
||
});
|
||
const [motivationalQuote, setMotivationalQuote] = useState("");
|
||
const [showSummary, setShowSummary] = useState(false);
|
||
|
||
const [isAddingSomedayList, setIsAddingSomedayList] = useState(false);
|
||
const [newSomedayListName, setNewSomedayListName] = useState("");
|
||
const [selectedSomedayProvider, setSelectedSomedayProvider] = useState<
|
||
string | null
|
||
>(null);
|
||
const [language, setLanguage] = useState("de");
|
||
const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "synced">(
|
||
"idle",
|
||
);
|
||
const [cellDuration, setCellDuration] = useState<CellDuration>(30);
|
||
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
|
||
const [showTimeGrid, setShowTimeGrid] = useState(true);
|
||
const [slideDirection, setSlideDirection] = useState<"next" | "prev" | 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;
|
||
listId?: string;
|
||
slotIdx?: number;
|
||
} | null>(null);
|
||
const [viewStyle, setViewStyle] = useState<ViewStyle>("simple");
|
||
const defaultKanbanStages: KanbanStage[] = [
|
||
{ id: "backlog", name: "Backlog", color: "#94a3b8" },
|
||
{ id: "todo", name: "To Do", color: "#3b82f6" },
|
||
{ id: "in-progress", name: "In Progress", color: "#f59e0b" },
|
||
{ id: "review", name: "Review", color: "#8b5cf6" },
|
||
{ id: "done", name: "Done", color: "#22c55e" },
|
||
];
|
||
const [kanbanStages, setKanbanStages] = useState<KanbanStage[]>(defaultKanbanStages);
|
||
const saveKanbanStages = async (stages: KanbanStage[]) => {
|
||
setKanbanStages(stages);
|
||
try {
|
||
await fetch("/api/user/profile", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ kanbanStages: JSON.stringify(stages) }),
|
||
});
|
||
} catch (e) { console.error("Failed to save kanban stages:", e); }
|
||
};
|
||
const [protectEventTimes, setProtectEventTimes] = useState(false);
|
||
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
|
||
|
||
const [startHour, setStartHour] = useState(8);
|
||
const [endHour, setEndHour] = useState(18);
|
||
const [weekStartDay, setWeekStartDay] = useState(1); // 1 = Monday, 0 = Sunday
|
||
const [showSomeday, setShowSomeday] = useState(true);
|
||
const [showAllDay, setShowAllDay] = useState(true);
|
||
const [goal, setGoal] = useState("your goal of this week");
|
||
const [isEditingGoal, setIsEditingGoal] = useState(false);
|
||
const [showNextTask, setShowNextTask] = useState(false);
|
||
const [calendarEditMode, setCalendarEditMode] = useState(false);
|
||
const [selectedTaskForRecurrence, setSelectedTaskForRecurrence] =
|
||
useState<Task | null>(null);
|
||
const [showFocusMode, setShowFocusMode] = useState(false);
|
||
const [showSchedule, setShowSchedule] = useState(true);
|
||
const [focusBreakDuration, setFocusBreakDuration] = useState(5);
|
||
const [weekdayFormat, setWeekdayFormat] = useState<"long" | "short" | "narrow" | "custom">("long");
|
||
const [weekdayCase, setWeekdayCase] = useState<"normal" | "capitalize" | "uppercase">("capitalize");
|
||
const [customWeekdayNames, setCustomWeekdayNames] = useState("");
|
||
|
||
// New UI State
|
||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||
const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false);
|
||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||
const datePickerBtnRef = useRef<HTMLButtonElement>(null);
|
||
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
||
|
||
const [focusTimerDuration, setFocusTimerDuration] = useState(25);
|
||
const [fontSize, setFontSize] = useState<"S" | "M" | "L">("M");
|
||
const [headlineFont, setHeadlineFont] = useState("Inter");
|
||
const [headlineFontSize, setHeadlineFontSize] = useState("1.25rem");
|
||
const [headlineFontWeight, setHeadlineFontWeight] = useState("900");
|
||
const [dateFontFamily, setDateFontFamily] = useState("Inter");
|
||
const [dateFontSize, setDateFontSize] = useState("0.65rem");
|
||
const [dateFontWeight, setDateFontWeight] = useState("400");
|
||
const [timeTaskFontFamily, setTimeTaskFontFamily] = useState("Inter");
|
||
const [timeTaskFontSize, setTimeTaskFontSize] = useState("0.75rem");
|
||
const [timeTaskFontWeight, setTimeTaskFontWeight] = useState("500");
|
||
const [bodyFont, setBodyFont] = useState("Inter");
|
||
const [taskFontFamily, setTaskFontFamily] = useState("Inter");
|
||
const [taskFontSize, setTaskFontSize] = useState("0.9rem");
|
||
const [taskFontWeight, setTaskFontWeight] = useState("400");
|
||
const [eventFontFamily, setEventFontFamily] = useState("Inter");
|
||
const [eventFontSize, setEventFontSize] = useState("0.85rem");
|
||
const [eventFontWeight, setEventFontWeight] = useState("400");
|
||
const [fontWeight, setFontWeight] = useState("400");
|
||
const [weekendColorSat, setWeekendColorSat] = useState("#666666");
|
||
const [weekendColorSun, setWeekendColorSun] = useState("#dc2626");
|
||
|
||
// Load fonts
|
||
// Dynamic font loading is handled by the main useGoogleFonts hook call below
|
||
|
||
// Collect custom font names from profile settings
|
||
const customFonts = useMemo(() => {
|
||
const fontProps = [
|
||
profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily,
|
||
profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily,
|
||
profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont,
|
||
];
|
||
return fontProps.filter((f): f is string => !!f && isCustomFont(f));
|
||
}, [profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily,
|
||
profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily,
|
||
profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont]);
|
||
|
||
// Load ALL available fonts + any custom fonts at the top level
|
||
useGoogleFonts([
|
||
...AVAILABLE_FONTS.filter((f) => f.value !== "__custom__").map((f) => f.value),
|
||
...customFonts,
|
||
]);
|
||
|
||
// Dynamic font loading is handled by useGoogleFonts hook call above
|
||
|
||
// 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);
|
||
|
||
const [recurringDeleteModal, setRecurringDeleteModal] = useState<{
|
||
isOpen: boolean;
|
||
taskId: string | null;
|
||
}>({ isOpen: false, taskId: null });
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
const savedDarkMode = localStorage.getItem("weekly-dark-mode");
|
||
if (savedDarkMode) {
|
||
setDarkMode(JSON.parse(savedDarkMode));
|
||
}
|
||
const savedWeekStart = localStorage.getItem("weekly-week-start");
|
||
if (savedWeekStart) {
|
||
setWeekStartDay(Number(savedWeekStart));
|
||
}
|
||
}, []);
|
||
|
||
// Mobile detection — track viewport width
|
||
useEffect(() => {
|
||
const check = () => setIsMobile(window.innerWidth <= 768);
|
||
check();
|
||
window.addEventListener("resize", check);
|
||
return () => window.removeEventListener("resize", check);
|
||
}, []);
|
||
|
||
// Close mobile menu on outside click
|
||
useEffect(() => {
|
||
if (!showMobileMenu) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (mobileMenuRef.current && !mobileMenuRef.current.contains(e.target as Node)) {
|
||
setShowMobileMenu(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handler);
|
||
document.addEventListener("touchstart", handler as EventListener);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handler);
|
||
document.removeEventListener("touchstart", handler as EventListener);
|
||
};
|
||
}, [showMobileMenu]);
|
||
|
||
// Auto-focus FAB bottom sheet textarea
|
||
useEffect(() => {
|
||
if (showMobileFabSheet && fabTextareaRef.current) {
|
||
setTimeout(() => fabTextareaRef.current?.focus(), 100);
|
||
}
|
||
}, [showMobileFabSheet]);
|
||
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
if (!mounted) return;
|
||
localStorage.setItem("weekly-week-start", String(weekStartDay));
|
||
// REMOVED: Re-align current week start when start day changes
|
||
// This was forcing the view to snap to Monday, breaking the "Yesterday as first column" setting.
|
||
// setCurrentWeekStart(prev => getStartOfWeek(prev, weekStartDay));
|
||
}, [weekStartDay, 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);
|
||
const dayHeaderRef = useRef<HTMLElement>(null);
|
||
const somedayGridRef = useRef<HTMLDivElement>(null);
|
||
const somedaySectionRef = useRef<HTMLElement | null>(null);
|
||
|
||
// 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: number) => {
|
||
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 50;
|
||
case 120:
|
||
return 50;
|
||
default:
|
||
return 50;
|
||
}
|
||
};
|
||
|
||
// Working hours range (configurable)
|
||
const workingHoursStart = startHour;
|
||
const workingHoursEnd = endHour;
|
||
|
||
// Fetch calendar events
|
||
const fetchCalendarEvents = useCallback(async (forceRefresh = false) => {
|
||
startSync();
|
||
setIsFetchingCalendar(true);
|
||
try {
|
||
const response = await fetch("/api/calendar/sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
timeMin: new Date(
|
||
currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
timeMax: new Date(
|
||
currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
forceRefresh,
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const text = await response.text();
|
||
try {
|
||
const data = JSON.parse(text);
|
||
if (data.events) {
|
||
setRawCalendarEvents(data.events);
|
||
}
|
||
} catch (e) {
|
||
console.error(
|
||
"Failed to parse calendar sync response:",
|
||
text.substring(0, 100),
|
||
);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching calendar events:", error);
|
||
} finally {
|
||
setIsFetchingCalendar(false);
|
||
endSync();
|
||
}
|
||
}, [currentWeekStart, startSync, endSync]);
|
||
|
||
// Calendar Event Handlers
|
||
const handleEventSave = async (eventData: any) => {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout
|
||
|
||
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),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || "Failed to save event");
|
||
}
|
||
|
||
// Optimistically add/update from API response, then force refresh cache
|
||
const data = await res.json();
|
||
if (data.event) {
|
||
// Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime)
|
||
const ev = data.event;
|
||
const frontendEvent: CalendarEvent = {
|
||
id: ev.id,
|
||
title: ev.title,
|
||
startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '',
|
||
endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '',
|
||
source: ev.source,
|
||
calendarId: ev.calendarId,
|
||
calendarTitle: ev.calendarTitle,
|
||
calendarColor: ev.backgroundColor || ev.calendarColor,
|
||
};
|
||
setRawCalendarEvents(prev => {
|
||
if (eventData.id) {
|
||
return prev.map(e => e.id === eventData.id ? frontendEvent : e);
|
||
}
|
||
return [...prev, frontendEvent];
|
||
});
|
||
}
|
||
// Re-read from cache (not a force-refresh from provider, which could
|
||
// overwrite the optimistic update if the provider hasn't propagated yet).
|
||
// The backend already cached the event via upsertCachedEvent.
|
||
setTimeout(() => fetchCalendarEvents(false), 2000);
|
||
} catch (error: any) {
|
||
console.error("Error saving event:", error);
|
||
if (error.name === "AbortError") {
|
||
throw new Error("Request timed out. Please try again.");
|
||
}
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
};
|
||
|
||
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");
|
||
}
|
||
|
||
// Optimistically remove, then re-read cache
|
||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
||
setTimeout(() => fetchCalendarEvents(false), 2000);
|
||
} 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 and REFRESH all tasks to show virtual instances
|
||
setTasks((prev) => prev.map((t) => (t.id === taskId ? data.task : t)));
|
||
await fetchTasks();
|
||
} catch (error) {
|
||
console.error(error);
|
||
alert("Failed to save recurrence settings");
|
||
}
|
||
};
|
||
|
||
const fetchMotivationalQuote = useCallback(async () => {
|
||
if (profile.goalFallbackType !== "quote") return;
|
||
|
||
const urls = profile.quoteSourceUrls && profile.quoteSourceUrls.length > 0
|
||
? profile.quoteSourceUrls
|
||
: profile.quoteSourceUrl ? [profile.quoteSourceUrl] : [];
|
||
|
||
// Strategy: try sources until one works
|
||
for (const url of urls) {
|
||
try {
|
||
const res = await fetch(url);
|
||
if (!res.ok) continue;
|
||
const contentType = res.headers.get("content-type") || "";
|
||
if (!contentType.includes("application/json")) continue;
|
||
const data = await res.json();
|
||
|
||
let quoteText = "";
|
||
if (Array.isArray(data) && data.length > 0) {
|
||
const item = data[0];
|
||
quoteText = item.quote || item.text || item.content || (typeof item === 'string' ? item : "");
|
||
if (item.author) quoteText += ` - ${item.author}`;
|
||
} else if (data && typeof data === 'object') {
|
||
quoteText = data.quote || data.text || data.content || "";
|
||
if (data.author) quoteText += ` - ${data.author}`;
|
||
} else if (typeof data === 'string') {
|
||
quoteText = data;
|
||
}
|
||
|
||
if (quoteText) {
|
||
setMotivationalQuote(quoteText);
|
||
return; // Success!
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error fetching quote from ${url}:`, error);
|
||
}
|
||
}
|
||
|
||
// Final fallback: use local curated quotes in user-selected languages
|
||
const quoteLangs = profile.quoteLanguages && profile.quoteLanguages.length > 0
|
||
? profile.quoteLanguages
|
||
: [profile.language || "en"];
|
||
const randomLang = quoteLangs[Math.floor(Math.random() * quoteLangs.length)];
|
||
const localQuote = getRandomLocalQuote(randomLang);
|
||
if (localQuote) {
|
||
setMotivationalQuote(`${localQuote.text} — ${localQuote.author}`);
|
||
} else {
|
||
setMotivationalQuote(randomLang === "de" ? "Bleib fokussiert und produktiv." : "Stay focused and productive.");
|
||
}
|
||
}, [profile.goalFallbackType, profile.quoteSourceUrl, profile.quoteSourceUrls, profile.language, profile.quoteLanguages]);
|
||
|
||
// Fetch tasks on mount
|
||
useEffect(() => {
|
||
if (session) {
|
||
fetchTasks();
|
||
fetchConnections();
|
||
fetchCalendarEvents();
|
||
fetchMotivationalQuote();
|
||
}
|
||
}, [session, fetchMotivationalQuote]);
|
||
|
||
// Auto-open settings to calendar tab after OAuth redirect
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (params.get('openSettings') === 'calendars') {
|
||
setShowSettings(true);
|
||
setActiveTab('calendar');
|
||
// Clean up URL
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete('openSettings');
|
||
url.searchParams.delete('calendar');
|
||
window.history.replaceState({}, '', url.pathname);
|
||
// Refresh connections to pick up the new one
|
||
fetchConnections();
|
||
}
|
||
}, []);
|
||
|
||
// Periodic pull-sync from Google Tasks (every 2 minutes)
|
||
useEffect(() => {
|
||
if (!session) return;
|
||
const interval = setInterval(
|
||
async () => {
|
||
try {
|
||
const res = await fetch("/api/tasks/sync");
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.updated > 0 || data.deleted > 0 || data.created > 0) {
|
||
console.log(
|
||
`[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions, ${data.created || 0} new tasks`,
|
||
);
|
||
fetchTasks(); // Reload to reflect changes
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("[SYNC] Task sync error:", e);
|
||
setSyncError("Task sync failed");
|
||
setTimeout(() => setSyncError(null), 10000);
|
||
}
|
||
},
|
||
2 * 60 * 1000,
|
||
);
|
||
return () => clearInterval(interval);
|
||
}, [session]);
|
||
|
||
// Periodic background calendar cache refresh (every 2 minutes)
|
||
useEffect(() => {
|
||
if (!session) return;
|
||
const interval = setInterval(
|
||
async () => {
|
||
try {
|
||
const now = new Date();
|
||
const res = await fetch("/api/calendar/background-sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
timeMin: new Date(
|
||
now.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
timeMax: new Date(
|
||
now.getTime() + 14 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
forceRefresh: true,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.queued > 0 || data.refreshed > 0) {
|
||
// Cache was refreshed; re-fetch events after delay
|
||
setTimeout(() => fetchCalendarEvents(), 8000);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("[SYNC] Calendar sync error:", e);
|
||
setSyncError("Calendar sync failed");
|
||
setTimeout(() => setSyncError(null), 10000);
|
||
}
|
||
},
|
||
2 * 60 * 1000,
|
||
);
|
||
return () => clearInterval(interval);
|
||
}, [session, fetchCalendarEvents]);
|
||
|
||
async function fetchConnections() {
|
||
try {
|
||
setIsLoading(true);
|
||
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);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
const handleRemoveConnection = async (connectionId: string) => {
|
||
console.log("Disconnecting connection:", connectionId);
|
||
const res = await fetch(`/api/calendar/connections?id=${connectionId}`, {
|
||
method: "DELETE",
|
||
});
|
||
|
||
if (res.ok) {
|
||
// Update state immediately
|
||
setConnections((prev) => prev.filter((c) => c.id !== connectionId));
|
||
// Refresh connections to be sure
|
||
fetchConnections();
|
||
// Optionally refresh events too as they might be gone
|
||
fetchCalendarEvents();
|
||
} else {
|
||
const err = await res.json();
|
||
console.error("Failed to disconnect calendar", err);
|
||
throw new Error(err.error || "Unknown error");
|
||
}
|
||
};
|
||
|
||
// Refetch calendar events when week changes
|
||
useEffect(() => {
|
||
if (session) {
|
||
fetchCalendarEvents();
|
||
}
|
||
}, [currentWeekStart, session, fetchCalendarEvents]);
|
||
|
||
// Update current time every 30 seconds for the "Now" line and clock
|
||
useEffect(() => {
|
||
const interval = setInterval(() => {
|
||
setCurrentTime(new Date());
|
||
}, 30000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
// Compute the goal date key: for "week" scope, normalize to Monday of that week; for "day", use the exact date
|
||
const getGoalDateKey = useCallback(
|
||
(date: Date): string => {
|
||
const scope = profile.goalScope || "week";
|
||
if (scope === "day") {
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
return d.toISOString();
|
||
}
|
||
// Normalize to Monday of the week containing this date
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
const day = d.getDay(); // 0=Sun, 1=Mon, ...
|
||
const diff = day === 0 ? -6 : 1 - day; // Monday offset
|
||
d.setDate(d.getDate() + diff);
|
||
return d.toISOString();
|
||
},
|
||
[profile.goalScope],
|
||
);
|
||
|
||
const goalDateKey = useMemo(
|
||
() => getGoalDateKey(currentWeekStart),
|
||
[currentWeekStart, getGoalDateKey],
|
||
);
|
||
|
||
// Fetch goal for current week/day
|
||
useEffect(() => {
|
||
const fetchGoal = async () => {
|
||
try {
|
||
const res = await fetch(`/api/goal?weekStart=${goalDateKey}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setGoal(data.goal);
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to fetch goal:", err);
|
||
}
|
||
};
|
||
fetchGoal();
|
||
}, [goalDateKey]);
|
||
|
||
const saveGoal = async (newGoal: string) => {
|
||
setGoal(newGoal);
|
||
try {
|
||
const res = await fetch("/api/goal", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
weekStart: goalDateKey,
|
||
text: newGoal,
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
console.error("Goal save failed:", res.status);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error saving goal:", error);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// Horizontal scroll: convert vertical wheel to horizontal in someday area
|
||
// Callback ref ensures handler is attached as soon as element mounts
|
||
const somedayWheelCleanup = useRef<(() => void) | null>(null);
|
||
const somedaySectionRefCb = useCallback((node: HTMLElement | null) => {
|
||
// Cleanup previous
|
||
if (somedayWheelCleanup.current) {
|
||
somedayWheelCleanup.current();
|
||
somedayWheelCleanup.current = null;
|
||
}
|
||
somedaySectionRef.current = node;
|
||
if (!node) return;
|
||
|
||
const handler = (e: WheelEvent) => {
|
||
const grid = somedayGridRef.current;
|
||
if (!grid) return;
|
||
|
||
// Let native horizontal scroll (trackpad) pass through
|
||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
||
if (e.deltaY === 0) return;
|
||
|
||
// Only convert if grid has horizontal overflow
|
||
if (grid.scrollWidth <= grid.clientWidth + 1) return;
|
||
|
||
// Check boundaries - allow page scroll when at edges
|
||
const atLeft = grid.scrollLeft <= 0;
|
||
const atRight = grid.scrollLeft + grid.clientWidth >= grid.scrollWidth - 1;
|
||
if (e.deltaY < 0 && atLeft) return;
|
||
if (e.deltaY > 0 && atRight) return;
|
||
|
||
e.preventDefault();
|
||
grid.scrollLeft += e.deltaY;
|
||
};
|
||
|
||
node.addEventListener("wheel", handler, { passive: false });
|
||
somedayWheelCleanup.current = () => node.removeEventListener("wheel", handler);
|
||
}, []);
|
||
const saveSetting = async (key: string, value: any) => {
|
||
// Per-device settings: save to cookie ONLY (not DB) so each device keeps its own value
|
||
if (DEVICE_SETTINGS_KEYS.includes(key)) {
|
||
setCookie(`setting_${key}`, String(value));
|
||
return; // Don't write to DB — that would overwrite other devices
|
||
}
|
||
try {
|
||
await fetch("/api/user/profile", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ [key]: value }),
|
||
});
|
||
} catch (err) {
|
||
console.error(`Failed to save setting ${key}:`, err);
|
||
}
|
||
};
|
||
|
||
const handleSettingsChanged = (newSettings: any) => {
|
||
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);
|
||
setShowSomeday(newSettings.showSomeday);
|
||
setShowAllDay(newSettings.showAllDayEvents);
|
||
setShowSchedule(newSettings.showSchedule);
|
||
if (newSettings.weekdayFormat) setWeekdayFormat(newSettings.weekdayFormat);
|
||
if (newSettings.weekdayCase) setWeekdayCase(newSettings.weekdayCase);
|
||
if (newSettings.customWeekdayNames !== undefined) setCustomWeekdayNames(newSettings.customWeekdayNames);
|
||
setHeadlineFont(newSettings.headlineFont);
|
||
setHeadlineFontSize(newSettings.headlineFontSize);
|
||
setHeadlineFontWeight(newSettings.headlineFontWeight);
|
||
setDateFontFamily(newSettings.dateFontFamily);
|
||
setDateFontSize(newSettings.dateFontSize);
|
||
setDateFontWeight(newSettings.dateFontWeight);
|
||
setTimeTaskFontFamily(newSettings.timeTaskFontFamily);
|
||
setTimeTaskFontSize(newSettings.timeTaskFontSize);
|
||
setTimeTaskFontWeight(newSettings.timeTaskFontWeight);
|
||
setBodyFont(newSettings.bodyFont);
|
||
setTaskFontFamily(newSettings.taskFontFamily);
|
||
setTaskFontSize(newSettings.taskFontSize);
|
||
setTaskFontWeight(newSettings.taskFontWeight);
|
||
if (newSettings.eventFontFamily)
|
||
setEventFontFamily(newSettings.eventFontFamily);
|
||
if (newSettings.eventFontSize) setEventFontSize(newSettings.eventFontSize);
|
||
if (newSettings.eventFontWeight)
|
||
setEventFontWeight(newSettings.eventFontWeight);
|
||
if (newSettings.fontWeight) setFontWeight(newSettings.fontWeight);
|
||
if (newSettings.weekendColorSat)
|
||
setWeekendColorSat(newSettings.weekendColorSat);
|
||
if (newSettings.weekendColorSun)
|
||
setWeekendColorSun(newSettings.weekendColorSun);
|
||
|
||
setProfile((prev: any) => ({
|
||
...prev,
|
||
...newSettings,
|
||
weekdayColor: newSettings.weekdayColor || prev.weekdayColor,
|
||
dateColor: newSettings.dateColor || prev.dateColor,
|
||
taskColor: newSettings.taskColor || prev.taskColor,
|
||
todayHighlightColor:
|
||
newSettings.todayHighlightColor || prev.todayHighlightColor,
|
||
eventFontFamily: newSettings.eventFontFamily || prev.eventFontFamily,
|
||
eventFontSize: newSettings.eventFontSize || prev.eventFontSize,
|
||
eventFontWeight: newSettings.eventFontWeight || prev.eventFontWeight,
|
||
}));
|
||
|
||
// Custom start/end hours might affect task placement if we filter strictly
|
||
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);
|
||
if (data.user.viewStyle !== undefined) {
|
||
setViewStyle(data.user.viewStyle as ViewStyle);
|
||
setShowTimeGrid(data.user.showTimeGrid ?? true);
|
||
}
|
||
if (data.user.kanbanStages) {
|
||
try {
|
||
const parsed = JSON.parse(data.user.kanbanStages);
|
||
if (Array.isArray(parsed) && parsed.length > 0) setKanbanStages(parsed);
|
||
} catch { /* use defaults */ }
|
||
}
|
||
if (data.user.viewDays !== undefined) {
|
||
savedViewDaysRef.current = data.user.viewDays;
|
||
const width = window.innerWidth;
|
||
if (width <= 480) setViewDays(1);
|
||
else if (width <= 768) setViewDays(3);
|
||
else if (width <= 1024) setViewDays(Math.min(data.user.viewDays, 5));
|
||
else setViewDays(data.user.viewDays);
|
||
}
|
||
if (data.user.cellDuration !== undefined)
|
||
setCellDuration(data.user.cellDuration as CellDuration);
|
||
|
||
// Cookie overrides for per-device settings (always apply, even if DB has no value)
|
||
const cookieViewDays = getCookie("setting_viewDays");
|
||
if (cookieViewDays) {
|
||
const v = Number(cookieViewDays);
|
||
savedViewDaysRef.current = v;
|
||
const width = window.innerWidth;
|
||
if (width <= 480) setViewDays(1);
|
||
else if (width <= 768) setViewDays(3);
|
||
else if (width <= 1024) setViewDays(Math.min(v, 5));
|
||
else setViewDays(v);
|
||
}
|
||
if (data.user.weekdayFormat) {
|
||
setProfile((prev: any) => ({ ...prev, weekdayFormat: data.user.weekdayFormat }));
|
||
}
|
||
if (data.user.customWeekdayNames) {
|
||
setProfile((prev: any) => ({ ...prev, customWeekdayNames: data.user.customWeekdayNames }));
|
||
}
|
||
const cookieCellDuration = getCookie("setting_cellDuration");
|
||
if (cookieCellDuration) setCellDuration(Number(cookieCellDuration) as CellDuration);
|
||
const cookieStartHour = getCookie("setting_startHour");
|
||
if (cookieStartHour) setStartHour(Number(cookieStartHour));
|
||
const cookieEndHour = getCookie("setting_endHour");
|
||
if (cookieEndHour) setEndHour(Number(cookieEndHour));
|
||
setShowNextTask(data.user.showNextTask || false);
|
||
setCalendarEditMode(data.user.calendarEditMode || false);
|
||
if (data.user.fontSize)
|
||
setFontSize(data.user.fontSize as "S" | "M" | "L");
|
||
|
||
if (data.user.showSomeday !== undefined)
|
||
setShowSomeday(data.user.showSomeday);
|
||
if (data.user.showAllDayEvents !== undefined)
|
||
setShowAllDay(data.user.showAllDayEvents);
|
||
if (data.user.showSchedule !== undefined)
|
||
setShowSchedule(data.user.showSchedule);
|
||
if (data.user.weekdayFormat)
|
||
setWeekdayFormat(data.user.weekdayFormat as any);
|
||
if (data.user.weekdayCase)
|
||
setWeekdayCase(data.user.weekdayCase as any);
|
||
if (data.user.customWeekdayNames)
|
||
setCustomWeekdayNames(data.user.customWeekdayNames);
|
||
if (data.user.hourLabelFormat)
|
||
setHourLabelFormat(data.user.hourLabelFormat as "short" | "full");
|
||
if (data.user.showSubHourSlots !== undefined)
|
||
setShowSubHourSlots(data.user.showSubHourSlots);
|
||
if (data.user.allDayPosition)
|
||
setAllDayPosition(data.user.allDayPosition as "above" | "below");
|
||
if (data.user.headlineFont) setHeadlineFont(data.user.headlineFont);
|
||
if (data.user.headlineFontSize)
|
||
setHeadlineFontSize(data.user.headlineFontSize);
|
||
if (data.user.headlineFontWeight)
|
||
setHeadlineFontWeight(data.user.headlineFontWeight);
|
||
if (data.user.dateFontFamily)
|
||
setDateFontFamily(data.user.dateFontFamily);
|
||
if (data.user.dateFontSize) setDateFontSize(data.user.dateFontSize);
|
||
if (data.user.dateFontWeight)
|
||
setDateFontWeight(data.user.dateFontWeight);
|
||
if (data.user.timeTaskFontFamily)
|
||
setTimeTaskFontFamily(data.user.timeTaskFontFamily);
|
||
if (data.user.timeTaskFontSize)
|
||
setTimeTaskFontSize(data.user.timeTaskFontSize);
|
||
if (data.user.timeTaskFontWeight)
|
||
setTimeTaskFontWeight(data.user.timeTaskFontWeight);
|
||
if (data.user.bodyFont) setBodyFont(data.user.bodyFont);
|
||
if (data.user.taskFontFamily)
|
||
setTaskFontFamily(data.user.taskFontFamily);
|
||
if (data.user.taskFontSize) setTaskFontSize(data.user.taskFontSize);
|
||
if (data.user.taskFontWeight)
|
||
setTaskFontWeight(data.user.taskFontWeight);
|
||
if (data.user.eventFontFamily)
|
||
setEventFontFamily(data.user.eventFontFamily);
|
||
if (data.user.eventFontSize)
|
||
setEventFontSize(data.user.eventFontSize);
|
||
if (data.user.eventFontWeight)
|
||
setEventFontWeight(data.user.eventFontWeight);
|
||
if (data.user.fontWeight) setFontWeight(data.user.fontWeight);
|
||
if (data.user.weekendColorSat)
|
||
setWeekendColorSat(data.user.weekendColorSat);
|
||
if (data.user.weekendColorSun)
|
||
setWeekendColorSun(data.user.weekendColorSun);
|
||
|
||
setProfile((prev) => ({
|
||
...prev,
|
||
...data.user,
|
||
name: data.user.name || prev.name,
|
||
email: data.user.email || prev.email,
|
||
weekdayColor: data.user.weekdayColor || "#888888",
|
||
dateColor: data.user.dateColor || "#888888",
|
||
taskColor: data.user.taskColor || "#333333",
|
||
todayHighlightColor: data.user.todayHighlightColor || "#f0fafa",
|
||
}));
|
||
|
||
// Apply start day offset (e.g. -1 for yesterday)
|
||
if (data.user.startDayOffset && data.user.startDayOffset !== 0) {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
d.setDate(d.getDate() + data.user.startDayOffset);
|
||
setCurrentWeekStart(d);
|
||
}
|
||
|
||
if (data.user.focusTimerDuration)
|
||
setFocusTimerDuration(data.user.focusTimerDuration);
|
||
if (data.user.focusBreakDuration)
|
||
setFocusBreakDuration(data.user.focusBreakDuration);
|
||
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 ViewStyle);
|
||
}
|
||
}
|
||
} 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,
|
||
tab: l.tab || null,
|
||
tasks: l.tasks || [],
|
||
externalId: l.externalId || null,
|
||
externalProvider: l.externalProvider || null,
|
||
})),
|
||
);
|
||
return data.lists;
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching someday lists:", error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function fetchProjects() {
|
||
try {
|
||
const res = await fetch("/api/projects");
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setProjects(data.projects || []);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching projects:", error);
|
||
}
|
||
}
|
||
|
||
async function fetchTasks() {
|
||
startSync();
|
||
try {
|
||
const [tasksResponse, listsResponse] = await Promise.all([
|
||
fetch("/api/tasks"),
|
||
fetch("/api/someday-lists"), // Fetch lists in parallel
|
||
]);
|
||
// Also fetch projects in background
|
||
fetchProjects();
|
||
|
||
let fetchedLists: SomedayList[] = [];
|
||
if (listsResponse.ok) {
|
||
const listData = await listsResponse.json();
|
||
fetchedLists = listData.lists.map((l: any) => ({
|
||
id: l.id,
|
||
title: l.title,
|
||
tab: l.tab || null,
|
||
tasks: [],
|
||
externalId: l.externalId || null,
|
||
externalProvider: l.externalProvider || null,
|
||
}));
|
||
}
|
||
|
||
// 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),
|
||
recurrenceDays: t.recurrenceDays ? (typeof t.recurrenceDays === 'string' ? JSON.parse(t.recurrenceDays) : t.recurrenceDays) : null,
|
||
}));
|
||
|
||
// Calendar tasks: anything NOT in a someday list (includes tasks with scheduledDate OR dayOfWeek)
|
||
const dayTasks = fetchedTasks.filter((t: Task) => !t.somedayListId);
|
||
const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId);
|
||
|
||
setTasks(dayTasks);
|
||
|
||
// Populate lists with tasks
|
||
const listIds = new Set(fetchedLists.map((l: SomedayList) => l.id));
|
||
const orphanedSomedayTasks = somedayTasks.filter(
|
||
(t: Task) => !listIds.has(t.somedayListId || ""),
|
||
);
|
||
|
||
const populatedLists = fetchedLists.map((list) => ({
|
||
...list,
|
||
tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id && !t.parentTaskId),
|
||
}));
|
||
|
||
// Rescue orphaned someday tasks: if their list was deleted, move them to calendar
|
||
if (orphanedSomedayTasks.length > 0) {
|
||
console.warn(
|
||
`[RESCUE] Found ${orphanedSomedayTasks.length} orphaned someday tasks, recovering to calendar`,
|
||
);
|
||
const rescuedTasks = orphanedSomedayTasks.map((t: Task) => ({
|
||
...t,
|
||
somedayListId: null,
|
||
scheduledDate: t.scheduledDate || new Date().toISOString(),
|
||
}));
|
||
setTasks((prev) => [...prev, ...rescuedTasks]);
|
||
// Persist the rescue to DB
|
||
for (const t of orphanedSomedayTasks) {
|
||
fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: t.id,
|
||
somedayListId: null,
|
||
scheduledDate: new Date().toISOString(),
|
||
}),
|
||
}).catch((e) =>
|
||
console.error("Failed to rescue orphaned task:", e),
|
||
);
|
||
}
|
||
}
|
||
|
||
setSomedayLists(populatedLists);
|
||
|
||
// Roll overdue tasks
|
||
if (dayTasks.length > 0) {
|
||
rollOverdueTasks(dayTasks);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching data:", error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
endSync();
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
// Exclude sub-tasks from top-level list (they render inside their parent)
|
||
if (task.parentTaskId) return false;
|
||
// Use string comparison to avoid timezone shifts
|
||
const taskDateStr =
|
||
typeof task.scheduledDate === "string"
|
||
? task.scheduledDate.substring(0, 10)
|
||
: 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;
|
||
// Exclude sub-tasks from top-level list
|
||
if (task.parentTaskId) return false;
|
||
// Use string comparison to avoid timezone shifts
|
||
const taskDateStr =
|
||
typeof task.scheduledDate === "string"
|
||
? task.scheduledDate.substring(0, 10)
|
||
: formatDateToISO(new Date(task.scheduledDate));
|
||
if (taskDateStr !== dateStr || !task.startTime) return false;
|
||
|
||
// Extract hour:minute from task start time and compare with slot
|
||
const [taskHour, taskMinute] = task.startTime.split(":").map(Number);
|
||
const taskStart = taskHour * 60 + taskMinute;
|
||
|
||
const [slotHour, slotMinute] = slot.split(":").map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
const slotEnd = slotStart + cellDuration;
|
||
|
||
return taskStart >= slotStart && taskStart < slotEnd;
|
||
});
|
||
},
|
||
[tasks, cellDuration],
|
||
);
|
||
|
||
// 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]);
|
||
|
||
const rollOverdueTasks = useCallback(
|
||
async (currentTasks: Task[]) => {
|
||
const autoRolling = profile.autoRolling ?? false;
|
||
if (!autoRolling) return;
|
||
|
||
const now = new Date();
|
||
const todayStr = formatDateToISO(now);
|
||
const today = new Date(todayStr);
|
||
|
||
const overdue = currentTasks.filter(
|
||
(t) =>
|
||
!t.completed &&
|
||
t.isRolling &&
|
||
t.scheduledDate &&
|
||
formatDateToISO(new Date(t.scheduledDate)) < todayStr,
|
||
);
|
||
|
||
if (overdue.length === 0) return;
|
||
|
||
console.log(
|
||
`[ROLLING] Found ${overdue.length} overdue tasks to roll to today. autoRolling=${autoRolling}`,
|
||
);
|
||
|
||
const updatedTasks = [...currentTasks];
|
||
let hasChanges = false;
|
||
|
||
const dailyEvents = getEventsForDate(today);
|
||
|
||
for (const task of overdue) {
|
||
const targetSlot = task.startTime || "09:00"; // Default to 9am if no time
|
||
|
||
// Collision detection
|
||
const isBlocked = (date: Date, slot: string, tasksToCheck: Task[]) => {
|
||
// Check other tasks in the updated list
|
||
const taskConflict = tasksToCheck.find(
|
||
(t) =>
|
||
t.id !== task.id &&
|
||
t.scheduledDate &&
|
||
formatDateToISO(new Date(t.scheduledDate)) ===
|
||
formatDateToISO(date) &&
|
||
t.startTime === slot,
|
||
);
|
||
if (taskConflict) return true;
|
||
|
||
// Check calendar events
|
||
const [h, m] = slot.split(":").map(Number);
|
||
const slotStart = new Date(date);
|
||
slotStart.setHours(h, m, 0, 0);
|
||
const slotEnd = new Date(slotStart);
|
||
slotEnd.setMinutes(slotEnd.getMinutes() + cellDuration);
|
||
|
||
return dailyEvents.some((event) => {
|
||
const eventStart = new Date(event.startTime);
|
||
const eventEnd = new Date(event.endTime);
|
||
return slotStart < eventEnd && slotEnd > eventStart;
|
||
});
|
||
};
|
||
|
||
const findFreeSlot = (
|
||
date: Date,
|
||
preferred: string,
|
||
tasksToCheck: Task[],
|
||
) => {
|
||
let current = preferred;
|
||
let [h, m] = current.split(":").map(Number);
|
||
|
||
while (isBlocked(date, current, tasksToCheck)) {
|
||
m += cellDuration;
|
||
if (m >= 60) {
|
||
h += 1;
|
||
m = 0;
|
||
}
|
||
if (h >= endHour) break;
|
||
current = `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
|
||
}
|
||
return current;
|
||
};
|
||
|
||
const nextSlot = findFreeSlot(today, targetSlot, updatedTasks);
|
||
|
||
// Update in DB
|
||
try {
|
||
const res = await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: task.id,
|
||
scheduledDate: todayStr,
|
||
startTime: nextSlot,
|
||
}),
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const taskIndex = updatedTasks.findIndex((t) => t.id === task.id);
|
||
if (taskIndex !== -1) {
|
||
updatedTasks[taskIndex] = {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
};
|
||
hasChanges = true;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`Failed to roll task ${task.id}:`, err);
|
||
}
|
||
}
|
||
|
||
if (hasChanges) {
|
||
setTasks(updatedTasks.filter((t) => !t.somedayListId));
|
||
}
|
||
},
|
||
[profile.autoRolling, cellDuration, endHour, getEventsForDate],
|
||
);
|
||
|
||
// 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],
|
||
);
|
||
|
||
// Check if a slot is occupied by any task (to prevent stacking)
|
||
const isSlotOccupiedByTask = useCallback(
|
||
(date: Date, slot: string, excludeTaskId?: string): boolean => {
|
||
const dateStr = formatDateToISO(date);
|
||
const [slotHour, slotMinute] = slot.split(":").map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
const slotEnd = slotStart + cellDuration;
|
||
|
||
return tasks.some(task => {
|
||
if (!task.scheduledDate || !task.startTime) return false;
|
||
if (excludeTaskId && task.id === excludeTaskId) return false;
|
||
// Exclude sub-tasks
|
||
if (task.parentTaskId) return false;
|
||
|
||
const taskDateStr =
|
||
typeof task.scheduledDate === "string"
|
||
? task.scheduledDate.substring(0, 10)
|
||
: formatDateToISO(new Date(task.scheduledDate));
|
||
if (taskDateStr !== dateStr) return false;
|
||
|
||
const [taskHour, taskMinute] = task.startTime.split(":").map(Number);
|
||
const taskStart = taskHour * 60 + taskMinute;
|
||
const taskDuration = task.duration || 15;
|
||
const taskEnd = taskStart + taskDuration;
|
||
|
||
// Skip completed tasks (they don't render in the grid)
|
||
if (task.completed) return false;
|
||
|
||
// Overlap condition: task starts before slot ends AND task ends after slot starts
|
||
return taskStart < slotEnd && taskEnd > slotStart;
|
||
});
|
||
},
|
||
[tasks, cellDuration],
|
||
);
|
||
|
||
// Navigation handlers with CSS class-based slide animation (works in all browsers)
|
||
const gridRef = useRef<HTMLElement>(null);
|
||
const allSlideClasses = ["slide-animate-next", "slide-animate-prev", "slide-animate-week-next", "slide-animate-week-prev"];
|
||
const navigate = (
|
||
newDate: Date,
|
||
direction: "left" | "right",
|
||
type: "day" | "week",
|
||
) => {
|
||
const grid = gridRef.current;
|
||
if (grid) {
|
||
// Remove any existing animation class
|
||
grid.classList.remove(...allSlideClasses);
|
||
// Trigger reflow to restart animation if same direction
|
||
void grid.offsetWidth;
|
||
// Pick class: week uses longer animation
|
||
const prefix = type === "week" ? "slide-animate-week-" : "slide-animate-";
|
||
grid.classList.add(prefix + (direction === "left" ? "next" : "prev"));
|
||
// Clean up after animation
|
||
const cleanup = () => {
|
||
grid.classList.remove(...allSlideClasses);
|
||
grid.removeEventListener("animationend", cleanup);
|
||
};
|
||
grid.addEventListener("animationend", cleanup, { once: true });
|
||
}
|
||
setCurrentWeekStart(newDate);
|
||
setSlideDirection(direction === "left" ? "next" : "prev");
|
||
};
|
||
|
||
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 = () => {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
d.setDate(d.getDate() + (profile?.startDayOffset || 0));
|
||
setCurrentWeekStart(d);
|
||
};
|
||
|
||
// Touch swipe navigation for mobile
|
||
useEffect(() => {
|
||
let touchStartX = 0;
|
||
let touchStartY = 0;
|
||
let touchEndX = 0;
|
||
let touchEndY = 0;
|
||
|
||
const handleTouchStart = (e: TouchEvent) => {
|
||
touchStartX = e.changedTouches[0].screenX;
|
||
touchStartY = e.changedTouches[0].screenY;
|
||
};
|
||
|
||
const handleTouchEnd = (e: TouchEvent) => {
|
||
touchEndX = e.changedTouches[0].screenX;
|
||
touchEndY = e.changedTouches[0].screenY;
|
||
const diffX = touchEndX - touchStartX;
|
||
const diffY = touchEndY - touchStartY;
|
||
// Only trigger if horizontal swipe is dominant and > 80px
|
||
if (Math.abs(diffX) > 80 && Math.abs(diffX) > Math.abs(diffY) * 1.5) {
|
||
if (diffX > 0) {
|
||
// Swipe right → go to previous day
|
||
goToPrevDay();
|
||
} else {
|
||
// Swipe left → go to next day
|
||
goToNextDay();
|
||
}
|
||
}
|
||
};
|
||
|
||
const container = document.querySelector('.weekly-container') as HTMLElement | null;
|
||
if (container) {
|
||
container.addEventListener('touchstart', handleTouchStart as EventListener, { passive: true });
|
||
container.addEventListener('touchend', handleTouchEnd as EventListener, { passive: true });
|
||
}
|
||
return () => {
|
||
if (container) {
|
||
container.removeEventListener('touchstart', handleTouchStart as EventListener);
|
||
container.removeEventListener('touchend', handleTouchEnd as EventListener);
|
||
}
|
||
};
|
||
}, [currentWeekStart]); // Re-attach when week changes so closures are fresh
|
||
|
||
const executeImport = async (provider: "google" | "apple" | "outlook" | "synology") => {
|
||
setImportProvider(provider);
|
||
setIsImportModalOpen(true);
|
||
setIsFetchingLists(true);
|
||
setImportLists([]);
|
||
setImportStatusMsg(null);
|
||
|
||
try {
|
||
const res = await fetch(`/api/tasks/lists?provider=${provider}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setImportLists(data.lists || []);
|
||
} else {
|
||
const errData = await res.json();
|
||
console.error("Failed to fetch lists", errData);
|
||
setIsImportModalOpen(false);
|
||
setImportStatusMsg({
|
||
type: "error",
|
||
text: errData.error || "Failed to fetch task lists.",
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error("Error fetching lists:", e);
|
||
setIsImportModalOpen(false);
|
||
setImportStatusMsg({ type: "error", text: "Error fetching task lists." });
|
||
} finally {
|
||
setIsFetchingLists(false);
|
||
}
|
||
};
|
||
|
||
const fetchAvailableTaskLists = useCallback(
|
||
async (provider: "google" | "apple" | "outlook" | "synology") => {
|
||
setIsFetchingProviderLists((prev) => ({
|
||
...prev,
|
||
[provider]: true,
|
||
}));
|
||
try {
|
||
const res = await fetch(`/api/tasks/lists?provider=${provider}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setAvailableTaskLists((prev) => ({
|
||
...prev,
|
||
[provider]: data.lists || [],
|
||
}));
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to fetch lists for ${provider}`, error);
|
||
} finally {
|
||
setIsFetchingProviderLists((prev) => ({
|
||
...prev,
|
||
[provider]: false,
|
||
}));
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
|
||
const handleToggleTaskList = async (
|
||
provider: "google" | "apple" | "outlook" | "synology",
|
||
list: { id: string; title: string },
|
||
) => {
|
||
const existing = somedayLists.find(
|
||
(l) => l.externalId === list.id && l.externalProvider === provider,
|
||
);
|
||
|
||
if (existing) {
|
||
// Show inline confirmation instead of browser confirm()
|
||
setUnsyncConfirm({ provider, list });
|
||
return;
|
||
} else {
|
||
// Sync/Import
|
||
await doImport(provider, [list]);
|
||
}
|
||
};
|
||
|
||
const confirmUnsync = async () => {
|
||
if (!unsyncConfirm) return;
|
||
const { provider, list } = unsyncConfirm;
|
||
const existing = somedayLists.find(
|
||
(l) => l.externalId === list.id && l.externalProvider === provider,
|
||
);
|
||
if (!existing) { setUnsyncConfirm(null); return; }
|
||
try {
|
||
const res = await fetch(`/api/someday-lists?id=${existing.id}`, {
|
||
method: "DELETE",
|
||
});
|
||
if (res.ok) {
|
||
setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id));
|
||
setImportStatusMsg({
|
||
type: "success",
|
||
text: `Stopped syncing "${list.title}".`,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to delete list", error);
|
||
setImportStatusMsg({
|
||
type: "error",
|
||
text: "Failed to stop syncing list.",
|
||
});
|
||
}
|
||
setUnsyncConfirm(null);
|
||
};
|
||
|
||
const handleSyncAll = async (
|
||
provider: "google" | "outlook" | "synology",
|
||
lists: { id: string; title: string }[],
|
||
syncOn: boolean,
|
||
) => {
|
||
if (syncOn) {
|
||
const unsynced = lists.filter(
|
||
(list) => !somedayLists.some(
|
||
(sl) => sl.externalId === list.id && sl.externalProvider === provider,
|
||
),
|
||
);
|
||
if (unsynced.length > 0) await doImport(provider, unsynced);
|
||
} else {
|
||
// Unsync all synced lists
|
||
const synced = lists.filter(
|
||
(list) => somedayLists.some(
|
||
(sl) => sl.externalId === list.id && sl.externalProvider === provider,
|
||
),
|
||
);
|
||
for (const list of synced) {
|
||
const existing = somedayLists.find(
|
||
(l) => l.externalId === list.id && l.externalProvider === provider,
|
||
);
|
||
if (!existing) continue;
|
||
try {
|
||
const res = await fetch(`/api/someday-lists?id=${existing.id}`, {
|
||
method: "DELETE",
|
||
});
|
||
if (res.ok) {
|
||
setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id));
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to unsync list", error);
|
||
}
|
||
}
|
||
setImportStatusMsg({
|
||
type: "success",
|
||
text: `Stopped syncing ${synced.length} list(s).`,
|
||
});
|
||
}
|
||
};
|
||
|
||
// Core import logic — accepts provider directly so it works both from modal and sidebar
|
||
const doImport = async (
|
||
provider: "google" | "apple" | "outlook" | "synology",
|
||
selectedLists: { id: string; title: string }[],
|
||
) => {
|
||
setImportingTasksState(true);
|
||
setImportStatusMsg(null);
|
||
|
||
try {
|
||
const response = await fetch("/api/tasks/import", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ provider, sourceLists: selectedLists }),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (response.ok) {
|
||
setImportStatusMsg({
|
||
type: "success",
|
||
text: `Synced ${data.count} new tasks across ${data.listsCreated || 1} list(s).`,
|
||
});
|
||
// Trigger immediate pull-sync to get latest state
|
||
try {
|
||
await fetch("/api/tasks/sync");
|
||
} catch (e) {
|
||
// Non-critical, auto-sync will catch up
|
||
}
|
||
await fetchTasks();
|
||
} else {
|
||
setImportStatusMsg({
|
||
type: "error",
|
||
text: data.error || "Sync failed.",
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("Import error:", error);
|
||
setImportStatusMsg({
|
||
type: "error",
|
||
text: "An error occurred during sync.",
|
||
});
|
||
} finally {
|
||
setImportingTasksState(false);
|
||
}
|
||
};
|
||
|
||
// Called from the Google Tasks modal
|
||
const handleConfirmImport = async (
|
||
selectedLists: { id: string; title: string }[],
|
||
) => {
|
||
if (!importProvider) return;
|
||
setIsImportModalOpen(false);
|
||
await doImport(importProvider, selectedLists);
|
||
setImportProvider(null);
|
||
};
|
||
|
||
// Undo/Redo helpers
|
||
const saveSnapshot = useCallback(() => {
|
||
if (skipSnapshotRef.current) return;
|
||
undoStackRef.current = [
|
||
...undoStackRef.current.slice(-29), // keep last 30 snapshots
|
||
{
|
||
tasks: JSON.parse(JSON.stringify(tasks)),
|
||
somedayLists: JSON.parse(JSON.stringify(somedayLists)),
|
||
},
|
||
];
|
||
redoStackRef.current = [];
|
||
setUndoCount(undoStackRef.current.length);
|
||
setRedoCount(0);
|
||
}, [tasks, somedayLists]);
|
||
|
||
const handleUndo = useCallback(() => {
|
||
if (undoStackRef.current.length === 0) return;
|
||
const snapshot = undoStackRef.current.pop()!;
|
||
redoStackRef.current.push({
|
||
tasks: JSON.parse(JSON.stringify(tasks)),
|
||
somedayLists: JSON.parse(JSON.stringify(somedayLists)),
|
||
});
|
||
skipSnapshotRef.current = true;
|
||
setTasks(snapshot.tasks);
|
||
setSomedayLists(snapshot.somedayLists);
|
||
skipSnapshotRef.current = false;
|
||
setUndoCount(undoStackRef.current.length);
|
||
setRedoCount(redoStackRef.current.length);
|
||
}, [tasks, somedayLists]);
|
||
|
||
const handleRedo = useCallback(() => {
|
||
if (redoStackRef.current.length === 0) return;
|
||
const snapshot = redoStackRef.current.pop()!;
|
||
undoStackRef.current.push({
|
||
tasks: JSON.parse(JSON.stringify(tasks)),
|
||
somedayLists: JSON.parse(JSON.stringify(somedayLists)),
|
||
});
|
||
skipSnapshotRef.current = true;
|
||
setTasks(snapshot.tasks);
|
||
setSomedayLists(snapshot.somedayLists);
|
||
skipSnapshotRef.current = false;
|
||
setUndoCount(undoStackRef.current.length);
|
||
setRedoCount(redoStackRef.current.length);
|
||
}, [tasks, somedayLists]);
|
||
|
||
// Keyboard shortcuts for undo/redo
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleUndo();
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === "y" || (e.key === "z" && e.shiftKey))) {
|
||
e.preventDefault();
|
||
handleRedo();
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [handleUndo, handleRedo]);
|
||
|
||
// Task CRUD operations
|
||
const addTask = async (date: Date, title: string, startTime?: string) => {
|
||
if (!title.trim()) return;
|
||
saveSnapshot();
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Helper to find a task in both calendar tasks and someday lists
|
||
const findTaskAnywhere = (taskId: string): Task | undefined => {
|
||
const calTask = tasks.find((t) => t.id === taskId);
|
||
if (calTask) return calTask;
|
||
for (const list of somedayLists) {
|
||
const found = list.tasks.find((t) => t.id === taskId);
|
||
if (found) return found;
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
const toggleTask = async (taskId: string) => {
|
||
saveSnapshot();
|
||
const task = findTaskAnywhere(taskId);
|
||
if (!task) return;
|
||
|
||
const updatedCompleted = !task.completed;
|
||
const isSomeday = !!task.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, completed: updatedCompleted, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
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 }),
|
||
});
|
||
|
||
if (task.externalId && task.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId: taskId, completed: updatedCompleted }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error toggling task:", error);
|
||
}
|
||
};
|
||
|
||
const updateTask = async (taskId: string, newTitle: string) => {
|
||
saveSnapshot();
|
||
if (!newTitle.trim()) {
|
||
await deleteTask(taskId);
|
||
return;
|
||
}
|
||
|
||
const task = findTaskAnywhere(taskId);
|
||
const isSomeday = !!task?.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, title: newTitle.trim(), updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
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() }),
|
||
});
|
||
|
||
if (task?.externalId && task?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, title: newTitle.trim() }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} 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);
|
||
}
|
||
};
|
||
|
||
// Sub-task CRUD operations
|
||
const addSubTask = async (parentId: string, title: string) => {
|
||
if (!title.trim() || !session?.user) return;
|
||
|
||
// Find parent task to inherit scheduling
|
||
const parentTask = findTaskAnywhere(parentId);
|
||
|
||
try {
|
||
const response = await fetch("/api/tasks", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title: title.trim(),
|
||
parentTaskId: parentId,
|
||
scheduledDate: parentTask?.scheduledDate || null,
|
||
dayOfWeek: parentTask?.dayOfWeek ?? null,
|
||
order: (parentTask?.subTasks?.length || 0),
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const newSubTask = {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
};
|
||
|
||
// Update local state: add sub-task to parent
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === parentId
|
||
? { ...t, subTasks: [...(t.subTasks || []), newSubTask] }
|
||
: t,
|
||
),
|
||
);
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === parentId
|
||
? { ...t, subTasks: [...(t.subTasks || []), newSubTask] }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error adding sub-task:", error);
|
||
}
|
||
};
|
||
|
||
const toggleSubTask = async (subTaskId: string) => {
|
||
// Find the sub-task in any parent
|
||
let foundSubTask: Task | undefined;
|
||
for (const task of tasks) {
|
||
foundSubTask = task.subTasks?.find((st) => st.id === subTaskId);
|
||
if (foundSubTask) break;
|
||
}
|
||
if (!foundSubTask) {
|
||
for (const list of somedayLists) {
|
||
for (const task of list.tasks) {
|
||
foundSubTask = task.subTasks?.find((st) => st.id === subTaskId);
|
||
if (foundSubTask) break;
|
||
}
|
||
if (foundSubTask) break;
|
||
}
|
||
}
|
||
if (!foundSubTask) return;
|
||
|
||
const newCompleted = !foundSubTask.completed;
|
||
|
||
// Optimistic update
|
||
const updateSubTasks = (taskList: Task[]) =>
|
||
taskList.map((t) => ({
|
||
...t,
|
||
subTasks: t.subTasks?.map((st) =>
|
||
st.id === subTaskId ? { ...st, completed: newCompleted } : st,
|
||
),
|
||
}));
|
||
|
||
setTasks((prev) => updateSubTasks(prev));
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({ ...l, tasks: updateSubTasks(l.tasks) })),
|
||
);
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: subTaskId, completed: newCompleted }),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error toggling sub-task:", error);
|
||
}
|
||
};
|
||
|
||
const deleteSubTask = async (subTaskId: string) => {
|
||
// Optimistic update: remove from parent's subTasks
|
||
const removeSubTask = (taskList: Task[]) =>
|
||
taskList.map((t) => ({
|
||
...t,
|
||
subTasks: t.subTasks?.filter((st) => st.id !== subTaskId),
|
||
}));
|
||
|
||
setTasks((prev) => removeSubTask(prev));
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({ ...l, tasks: removeSubTask(l.tasks) })),
|
||
);
|
||
|
||
try {
|
||
await fetch(`/api/tasks?id=${subTaskId}`, { method: "DELETE" });
|
||
} catch (error) {
|
||
console.error("Error deleting sub-task:", error);
|
||
}
|
||
};
|
||
|
||
const updateSubTask = async (subTaskId: string, newTitle: string) => {
|
||
if (!newTitle.trim()) {
|
||
await deleteSubTask(subTaskId);
|
||
return;
|
||
}
|
||
|
||
const updateSubTasks = (taskList: Task[]) =>
|
||
taskList.map((t) => ({
|
||
...t,
|
||
subTasks: t.subTasks?.map((st) =>
|
||
st.id === subTaskId ? { ...st, title: newTitle.trim() } : st,
|
||
),
|
||
}));
|
||
|
||
setTasks((prev) => updateSubTasks(prev));
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({ ...l, tasks: updateSubTasks(l.tasks) })),
|
||
);
|
||
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: subTaskId, title: newTitle.trim() }),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error updating sub-task:", 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) => {
|
||
const task = findTaskAnywhere(taskId);
|
||
const isSomeday = !!task?.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, markdownContent: notes, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
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 }),
|
||
});
|
||
|
||
if (task?.externalId && task?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, notes }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error updating task notes:", error);
|
||
}
|
||
};
|
||
|
||
const toggleTaskRolling = async (taskId: string) => {
|
||
saveSnapshot();
|
||
const task = findTaskAnywhere(taskId);
|
||
if (!task) return;
|
||
|
||
const newRollingState = !task.isRolling;
|
||
const isSomeday = !!task.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId
|
||
? { ...t, isRolling: newRollingState, updatedAt: new Date() }
|
||
: t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
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);
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.map((t) =>
|
||
t.id === taskId ? { ...t, isRolling: !newRollingState } : t,
|
||
),
|
||
})),
|
||
);
|
||
} else {
|
||
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;
|
||
const task = tasks.find((t) => t.id === taskId);
|
||
setTasks(
|
||
tasks.map((t) =>
|
||
t.id === taskId
|
||
? {
|
||
...t,
|
||
dayOfWeek,
|
||
startTime,
|
||
scheduledDate: newScheduledDate || t.scheduledDate,
|
||
somedayListId: null,
|
||
somedaySlotIndex: null,
|
||
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,
|
||
somedayListId: null,
|
||
somedaySlotIndex: null,
|
||
}),
|
||
});
|
||
|
||
// Sync due date change to external provider
|
||
if (task?.externalId && task?.externalProvider && newScheduledDate) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} catch (error) {
|
||
console.error("Error moving task:", error);
|
||
}
|
||
};
|
||
|
||
const assignProject = async (taskId: string, projectId: string | null) => {
|
||
const proj = projectId ? projects.find((p) => p.id === projectId) || null : null;
|
||
// Optimistic update
|
||
const updateTask = (t: Task) =>
|
||
t.id === taskId ? { ...t, projectId: projectId, project: proj } : t;
|
||
setTasks((prev) => prev.map(updateTask));
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({ ...l, tasks: l.tasks.map(updateTask) }))
|
||
);
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: taskId, projectId }),
|
||
});
|
||
} catch (e) {
|
||
console.error("Failed to assign project:", e);
|
||
}
|
||
};
|
||
|
||
const deleteTask = async (taskId: string) => {
|
||
saveSnapshot();
|
||
const taskToDelete = findTaskAnywhere(taskId);
|
||
const isSomeday = !!taskToDelete?.somedayListId;
|
||
const isVirtual = taskId.startsWith("virtual-");
|
||
|
||
let originalId = taskId;
|
||
if (isVirtual) {
|
||
const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/);
|
||
if (match) {
|
||
originalId = match[1];
|
||
}
|
||
}
|
||
|
||
// Check if it's a series (virtual or real recurring)
|
||
const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring);
|
||
|
||
if (isSeries) {
|
||
setRecurringDeleteModal({ isOpen: true, taskId });
|
||
return;
|
||
}
|
||
|
||
// NORMAL DELETE (Single instance)
|
||
if (isSomeday) {
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => ({
|
||
...l,
|
||
tasks: l.tasks.filter((t) => t.id !== taskId),
|
||
})),
|
||
);
|
||
} else {
|
||
setTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||
}
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
if (taskToDelete?.externalId && taskToDelete?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, action: "delete" }),
|
||
}).catch((e) => console.error("Sync delete error:", e));
|
||
}
|
||
|
||
await fetch(`/api/tasks?id=${taskId}`, { method: "DELETE" });
|
||
} catch (error) {
|
||
console.error("Error deleting task:", error);
|
||
}
|
||
};
|
||
|
||
const handleConfirmDeleteSeries = async (taskId: string) => {
|
||
let originalId = taskId;
|
||
if (taskId.startsWith("virtual-")) {
|
||
const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/);
|
||
if (match) originalId = match[1];
|
||
}
|
||
|
||
const taskToDelete = findTaskAnywhere(originalId);
|
||
|
||
setTasks((prev) =>
|
||
prev.filter((t) => {
|
||
if (taskToDelete && t.title === taskToDelete.title &&
|
||
t.recurrenceInterval === taskToDelete.recurrenceInterval &&
|
||
t.recurrenceUnit === taskToDelete.recurrenceUnit) {
|
||
return false;
|
||
}
|
||
if (t.id === originalId) return false;
|
||
if (t.id.startsWith(`virtual-${originalId}-`)) return false;
|
||
if (t.id === taskId) return false;
|
||
return true;
|
||
}),
|
||
);
|
||
setEditingTaskId(null);
|
||
setRecurringDeleteModal({ isOpen: false, taskId: null });
|
||
|
||
try {
|
||
const origTask = findTaskAnywhere(originalId);
|
||
if (origTask?.externalId && origTask?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId: originalId, action: "delete" }),
|
||
}).catch((e) => console.error("Sync delete error:", e));
|
||
}
|
||
await fetch(`/api/tasks?id=${originalId}`, { method: "DELETE" });
|
||
} catch (error) {
|
||
console.error("Error deleting series:", error);
|
||
}
|
||
};
|
||
|
||
const handleConfirmDeleteOccurrence = async (taskId: string) => {
|
||
setTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||
setEditingTaskId(null);
|
||
setRecurringDeleteModal({ isOpen: false, taskId: null });
|
||
|
||
try {
|
||
const taskToDelete = findTaskAnywhere(taskId);
|
||
if (taskToDelete?.externalId && taskToDelete?.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, action: "delete" }),
|
||
}).catch((e) => console.error("Sync delete error:", e));
|
||
}
|
||
await fetch(`/api/tasks?id=${taskId}`, { method: "DELETE" });
|
||
} catch (error) {
|
||
console.error("Error deleting instance:", 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,
|
||
}),
|
||
});
|
||
|
||
// Sync due date change to external provider
|
||
if (task.externalId && task.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} 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,
|
||
) => {
|
||
// Reject someday list drags on day slots
|
||
if (draggingListId) {
|
||
e.preventDefault();
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.dropEffect = "none";
|
||
}
|
||
return;
|
||
}
|
||
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) {
|
||
if (isSlotOccupiedByTask(targetDateObj, targetSlot, 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];
|
||
if (!isSlotOccupiedByTask(targetDateObj, nextSlot, draggedTask.id) && !isSlotProtected(targetDateObj, nextSlot)) {
|
||
targetSlot = nextSlot;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If the task is a subtask, promote it to a standalone task
|
||
if (draggedTask.parentTaskId) {
|
||
const newScheduledDate = formatDateToISO(targetDateObj);
|
||
// Remove subtask from parent in UI
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === draggedTask.parentTaskId
|
||
? { ...t, subTasks: (t.subTasks || []).filter((s) => s.id !== draggedTask.id) }
|
||
: t
|
||
)
|
||
);
|
||
// Add as standalone task in UI
|
||
setTasks((prev) => [
|
||
...prev,
|
||
{
|
||
...draggedTask,
|
||
parentTaskId: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || "",
|
||
} as Task,
|
||
]);
|
||
// Persist
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: draggedTask.id,
|
||
parentTaskId: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || "",
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error promoting subtask:", error);
|
||
}
|
||
setDraggedTask(null);
|
||
setDropPreview(null);
|
||
return;
|
||
}
|
||
|
||
// If the task was from a someday list, move it to the calendar
|
||
if (draggedTask.somedayListId) {
|
||
const newScheduledDate = formatDateToISO(targetDateObj);
|
||
// Inherit provider from someday list if task doesn't have one
|
||
const sourceList = somedayLists.find(l => l.id === draggedTask.somedayListId);
|
||
const taskProvider = draggedTask.externalProvider || sourceList?.externalProvider || null;
|
||
// 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,
|
||
somedaySlotIndex: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || "",
|
||
externalProvider: taskProvider,
|
||
},
|
||
]);
|
||
// Persist
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: draggedTask.id,
|
||
somedayListId: null,
|
||
somedaySlotIndex: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || "",
|
||
...(taskProvider && { externalProvider: taskProvider }),
|
||
}),
|
||
});
|
||
|
||
// Sync due date to external provider when moving from someday to calendar
|
||
if (draggedTask.externalId && draggedTask.externalProvider) {
|
||
fetch("/api/tasks/sync", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
taskId: draggedTask.id,
|
||
scheduledDate: newScheduledDate,
|
||
}),
|
||
}).catch((e) => console.error("Sync error:", e));
|
||
}
|
||
} 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);
|
||
};
|
||
|
||
|
||
const handleSomedayDragOver = (e: React.DragEvent, listId: string, slotIdx: number) => {
|
||
e.preventDefault();
|
||
setDropPreview({ listId, slotIdx });
|
||
};
|
||
|
||
const handleSomedayDrop = async (e: React.DragEvent, listId: string, slotIndex: number) => {
|
||
e.preventDefault();
|
||
if (draggedTask) {
|
||
// If subtask, remove from parent first
|
||
if (draggedTask.parentTaskId) {
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === draggedTask.parentTaskId
|
||
? { ...t, subTasks: (t.subTasks || []).filter((s) => s.id !== draggedTask.id) }
|
||
: t
|
||
)
|
||
);
|
||
}
|
||
|
||
// Update local state for someday lists
|
||
setSomedayLists((prev) =>
|
||
prev.map((l) => {
|
||
// Remove the task from its current position in all lists
|
||
const filteredTasks = l.tasks.filter((t) => t.id !== draggedTask.id);
|
||
if (l.id === listId) {
|
||
const movedTask = {
|
||
...draggedTask,
|
||
parentTaskId: null,
|
||
somedayListId: listId,
|
||
somedaySlotIndex: slotIndex,
|
||
scheduledDate: null as any,
|
||
dayOfWeek: null as any,
|
||
startTime: null as any,
|
||
};
|
||
return {
|
||
...l,
|
||
tasks: [...filteredTasks, movedTask],
|
||
};
|
||
}
|
||
return { ...l, tasks: filteredTasks };
|
||
}),
|
||
);
|
||
|
||
// If it was a calendar task (not someday, not subtask), remove from calendar tasks
|
||
if (!draggedTask.somedayListId && !draggedTask.parentTaskId) {
|
||
setTasks((prev) => prev.filter((t) => t.id !== draggedTask.id));
|
||
}
|
||
|
||
// Persist the change
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: draggedTask.id,
|
||
parentTaskId: null,
|
||
somedayListId: listId,
|
||
somedaySlotIndex: slotIndex,
|
||
scheduledDate: null,
|
||
dayOfWeek: null,
|
||
startTime: null,
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
console.error("Error moving task to someday slot:", error);
|
||
}
|
||
setDraggedTask(null);
|
||
setDropPreview(null);
|
||
}
|
||
};
|
||
|
||
// Sync calendar
|
||
const handleSync = async () => {
|
||
setSyncStatus("syncing");
|
||
try {
|
||
// Pull changes from Google Tasks, then force-refresh calendar cache
|
||
await fetch("/api/tasks/sync").catch((e) =>
|
||
console.error("Task pull sync error:", e),
|
||
);
|
||
// Force live refresh from providers (bypass staleness check)
|
||
const syncRes = await fetch("/api/calendar/sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
timeMin: new Date(
|
||
currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
timeMax: new Date(
|
||
currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000,
|
||
).toISOString(),
|
||
forceRefresh: true,
|
||
}),
|
||
});
|
||
if (syncRes.ok) {
|
||
const data = await syncRes.json();
|
||
if (data.events) setRawCalendarEvents(data.events);
|
||
}
|
||
await fetchTasks();
|
||
// Re-fetch after background refresh completes
|
||
if (true) {
|
||
setTimeout(() => fetchCalendarEvents(), 8000);
|
||
}
|
||
setSyncStatus("synced");
|
||
setTimeout(() => setSyncStatus("idle"), 3000);
|
||
} catch (error) {
|
||
console.error("Error syncing:", error);
|
||
setSyncStatus("idle");
|
||
setSyncError("Sync failed");
|
||
setTimeout(() => setSyncError(null), 10000);
|
||
}
|
||
};
|
||
|
||
// 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("");
|
||
setSelectedSomedayProvider(null);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const url = selectedSomedayProvider
|
||
? "/api/someday-lists/external"
|
||
: "/api/someday-lists";
|
||
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title: newSomedayListName.trim(),
|
||
provider: selectedSomedayProvider,
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setSomedayLists((prev) => [
|
||
...prev,
|
||
{
|
||
...(data.somedayList || data.list),
|
||
tasks: [], // Initially empty
|
||
},
|
||
]);
|
||
setNewSomedayListName("");
|
||
setSelectedSomedayProvider(null);
|
||
setIsAddingSomedayList(false);
|
||
} else {
|
||
const error = await response.json();
|
||
alert(error.error || "Failed to create list");
|
||
}
|
||
} catch (error) {
|
||
console.error("Error adding someday list:", error);
|
||
alert("An error occurred while creating the list");
|
||
}
|
||
};
|
||
|
||
// Get time slots to display
|
||
// Get time slots to display
|
||
const visibleSlots = getTimeSlots(
|
||
cellDuration,
|
||
workingHoursStart,
|
||
workingHoursEnd,
|
||
);
|
||
|
||
const fontSizeScale = fontSize === "S" ? 0.85 : fontSize === "L" ? 1.15 : 1;
|
||
const fontVal = (v: string | undefined) => v && v !== "__custom__" ? v : "";
|
||
const scaleRem = (base: string) => {
|
||
const num = parseFloat(base);
|
||
return `${(num * fontSizeScale).toFixed(3)}rem`;
|
||
};
|
||
|
||
const containerStyle = {
|
||
"--weekly-font-headline":
|
||
fontVal(profile.headlineFont) || headlineFont
|
||
? `"${fontVal(profile.headlineFont) || headlineFont}", sans-serif`
|
||
: "var(--font-headline)",
|
||
"--weekly-headline-size": scaleRem(profile.headlineFontSize || "1.25rem"),
|
||
"--weekly-headline-weight": profile.headlineFontWeight || "900",
|
||
"--weekly-date-font": fontVal(profile.dateFontFamily)
|
||
? `"${fontVal(profile.dateFontFamily)}", sans-serif`
|
||
: "var(--weekly-font-headline)",
|
||
"--weekly-date-size": scaleRem(profile.dateFontSize || "0.65rem"),
|
||
"--weekly-date-weight": profile.dateFontWeight || "400",
|
||
"--weekly-time-task-font": (() => {
|
||
// If time task font is explicitly customized (not default "Inter"), use it
|
||
// Otherwise inherit from task font
|
||
const ttf = fontVal(profile.timeTaskFontFamily);
|
||
const tf = fontVal(profile.taskFontFamily);
|
||
const isDefault = !ttf || ttf === "Inter";
|
||
if (!isDefault) return `"${ttf}", sans-serif`;
|
||
if (tf) return `"${tf}", sans-serif`;
|
||
return "var(--weekly-font)";
|
||
})(),
|
||
"--weekly-time-task-size": scaleRem(
|
||
// If time task size is the old default 0.75rem, use task size instead
|
||
profile.timeTaskFontSize && profile.timeTaskFontSize !== "0.75rem"
|
||
? profile.timeTaskFontSize
|
||
: profile.taskFontSize || "0.9rem"
|
||
),
|
||
"--weekly-time-task-weight":
|
||
// If time task weight is the old default 500, use task weight instead
|
||
profile.timeTaskFontWeight && profile.timeTaskFontWeight !== "500"
|
||
? profile.timeTaskFontWeight
|
||
: profile.taskFontWeight || "400",
|
||
"--weekly-font":
|
||
"var(--font-body)" /* Force default body font as requested */,
|
||
"--weekly-task-font": fontVal(profile.taskFontFamily)
|
||
? `"${fontVal(profile.taskFontFamily)}", sans-serif`
|
||
: "var(--weekly-font)",
|
||
"--weekly-task-size": scaleRem(profile.taskFontSize || "0.9rem"),
|
||
"--weekly-task-weight": profile.taskFontWeight || "400",
|
||
"--weekly-event-font":
|
||
fontVal(profile.eventFontFamily) || eventFontFamily
|
||
? `"${fontVal(profile.eventFontFamily) || eventFontFamily}", sans-serif`
|
||
: "var(--weekly-font)",
|
||
"--weekly-event-size": scaleRem(profile.eventFontSize || eventFontSize || "0.85rem"),
|
||
"--weekly-event-weight":
|
||
profile.eventFontWeight || eventFontWeight || "400",
|
||
"--font-weight-body": profile.fontWeight || fontWeight || "400",
|
||
"--weekly-weekend-sat": darkMode
|
||
? invertColor(profile.weekendColorSat || "#666666")
|
||
: profile.weekendColorSat || "#666666",
|
||
"--weekly-weekend-sun": darkMode
|
||
? invertColor(profile.weekendColorSun || "#dc2626")
|
||
: profile.weekendColorSun || "#dc2626",
|
||
"--weekly-weekday-color": darkMode
|
||
? invertColor(profile.weekdayColor || "#888888")
|
||
: profile.weekdayColor || "#888888",
|
||
"--weekly-date-color": darkMode
|
||
? invertColor(profile.dateColor || "#888888")
|
||
: profile.dateColor || "#888888",
|
||
"--weekly-task-color": darkMode
|
||
? invertColor(profile.taskColor || "#333333")
|
||
: profile.taskColor || "#333333",
|
||
"--weekly-today-highlight": darkMode
|
||
? invertColor(profile.todayHighlightColor || "#f0fafa")
|
||
: profile.todayHighlightColor || "#f0fafa",
|
||
"--weekly-past-color": darkMode
|
||
? invertColor(profile.pastDayColor || "#a6a6a7")
|
||
: profile.pastDayColor || "#a6a6a7",
|
||
} as React.CSSProperties;
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
const activeDateLayout = isMobile ? (profile.mobileDateLayout || "below") : (profile.dateLayout || "right");
|
||
|
||
// All-Day Events Section (reusable for above/below positioning)
|
||
const allDaySection = (() => {
|
||
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 style={{ display: "flex", flexDirection: "row" }}>
|
||
{showTimeGrid && (
|
||
<div
|
||
className="all-day-label-column"
|
||
onClick={() => setIsAllDayExpanded(!isAllDayExpanded)}
|
||
style={{
|
||
width: "50px",
|
||
flexShrink: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
cursor: "pointer",
|
||
borderRight: "1px solid var(--weekly-border)",
|
||
padding: "2px 4px",
|
||
gap: "0px",
|
||
position: "relative",
|
||
}}
|
||
title={isAllDayExpanded ? "Collapse" : "Expand"}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: "0.6rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-text-light)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
lineHeight: 1.1,
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
all day
|
||
</span>
|
||
<span
|
||
className="all-day-events-count"
|
||
style={{
|
||
fontSize: "0.55rem",
|
||
padding: "0px 3px",
|
||
marginTop: "1px",
|
||
}}
|
||
>
|
||
{allDayEvents.length}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{!showTimeGrid && (
|
||
<div
|
||
className="all-day-label-column"
|
||
onClick={() => setIsAllDayExpanded(!isAllDayExpanded)}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
cursor: "pointer",
|
||
padding: "2px 8px",
|
||
gap: "6px",
|
||
}}
|
||
title={isAllDayExpanded ? "Collapse" : "Expand"}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: "0.6rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-text-light)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
}}
|
||
>
|
||
all day
|
||
</span>
|
||
<span
|
||
className="all-day-events-count"
|
||
style={{ fontSize: "0.55rem", padding: "0px 3px" }}
|
||
>
|
||
{allDayEvents.length}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{isAllDayExpanded && (
|
||
<div
|
||
className={`all-day-events-grid cols-${viewDays}`}
|
||
style={{ flex: 1 }}
|
||
>
|
||
{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>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
})();
|
||
|
||
return (
|
||
<div
|
||
className={`weekly-container ${darkMode ? "dark-mode" : ""} font-size-${fontSize.toLowerCase()} ${viewStyle}-view ${showTimeGrid ? "time-grid-on" : "time-grid-off"}`}
|
||
style={containerStyle}
|
||
>
|
||
{/* Quick Settings Sidebar (TeuxDeux-style) */}
|
||
{showQuickSettings && (
|
||
<>
|
||
<div
|
||
onClick={() => setShowQuickSettings(false)}
|
||
style={{ position: "fixed", inset: 0, zIndex: 999 }}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
left: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: "220px",
|
||
background: darkMode ? "#1a1a2e" : "#fafafa",
|
||
borderRight: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`,
|
||
zIndex: 1000,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
padding: "20px 16px",
|
||
gap: "18px",
|
||
overflowY: "auto",
|
||
boxShadow: "2px 0 12px rgba(0,0,0,0.08)",
|
||
}}
|
||
>
|
||
<div style={{ fontSize: "0.85rem", fontWeight: 700, color: darkMode ? "#e5e7eb" : "#333", marginBottom: "4px" }}>
|
||
{language === "de" ? "Einstellungen" : "Preferences"}
|
||
</div>
|
||
|
||
{/* Columns */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
|
||
{language === "de" ? "Spalten" : "Columns"}
|
||
</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
{[1, 3, 5, 7].map((num) => (
|
||
<button
|
||
key={num}
|
||
onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }}
|
||
style={{
|
||
padding: "4px 10px",
|
||
fontSize: "0.8rem",
|
||
borderRadius: "6px",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontWeight: viewDays === num ? 700 : 400,
|
||
background: viewDays === num ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
|
||
color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
|
||
}}
|
||
>
|
||
{num}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Text size */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
|
||
{language === "de" ? "Textgröße" : "Text size"}
|
||
</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
{(["S", "M", "L"] as const).map((size) => (
|
||
<button
|
||
key={size}
|
||
onClick={() => { setFontSize(size); saveSetting("fontSize", size); }}
|
||
style={{
|
||
padding: "4px 10px",
|
||
fontSize: "0.8rem",
|
||
borderRadius: "6px",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontWeight: fontSize === size ? 700 : 400,
|
||
background: fontSize === size ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
|
||
color: fontSize === size ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
|
||
}}
|
||
>
|
||
{size}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Someday section */}
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
|
||
{language === "de" ? "Irgendwann" : "Someday"}
|
||
</span>
|
||
<button
|
||
onClick={() => { const v = !showSomeday; setShowSomeday(v); saveSetting("showSomeday", v); }}
|
||
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}
|
||
>
|
||
{showSomeday ? <Eye size={16} /> : <EyeOff size={16} />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Schedule / Time Grid */}
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
|
||
{language === "de" ? "Zeitplan" : "Schedule"}
|
||
</span>
|
||
<button
|
||
onClick={() => { const v = !showTimeGrid; setShowTimeGrid(v); saveSetting("showTimeGrid", v); }}
|
||
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}
|
||
>
|
||
{showTimeGrid ? <Eye size={16} /> : <EyeOff size={16} />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* All-day events */}
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
|
||
{language === "de" ? "Ganztägig" : "All-day"}
|
||
</span>
|
||
<button
|
||
onClick={() => { const v = !showAllDay; setShowAllDay(v); saveSetting("showAllDayEvents", v); }}
|
||
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}
|
||
>
|
||
{showAllDay ? <Eye size={16} /> : <EyeOff size={16} />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Checkboxes */}
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
|
||
{language === "de" ? "Checkboxen" : "Checkboxes"}
|
||
</span>
|
||
<button
|
||
onClick={() => {
|
||
const v = !profile.showTaskCheckboxes;
|
||
setProfile({ ...profile, showTaskCheckboxes: v });
|
||
saveSetting("showTaskCheckboxes", v);
|
||
}}
|
||
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}
|
||
>
|
||
{profile.showTaskCheckboxes ? <Eye size={16} /> : <EyeOff size={16} />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Start on */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
|
||
{language === "de" ? "Starten mit" : "Start on"}
|
||
</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
<button
|
||
onClick={() => {
|
||
setProfile({ ...profile, startDayOffset: 0 });
|
||
saveSetting("startDayOffset", 0);
|
||
const d = new Date(); d.setHours(0, 0, 0, 0); setCurrentWeekStart(d);
|
||
}}
|
||
style={{
|
||
padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer",
|
||
fontWeight: (profile.startDayOffset || 0) === 0 ? 700 : 400,
|
||
background: (profile.startDayOffset || 0) === 0 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
|
||
color: (profile.startDayOffset || 0) === 0 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
|
||
}}
|
||
>
|
||
{language === "de" ? "Heute" : "Today"}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setProfile({ ...profile, startDayOffset: -1 });
|
||
saveSetting("startDayOffset", -1);
|
||
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); setCurrentWeekStart(d);
|
||
}}
|
||
style={{
|
||
padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer",
|
||
fontWeight: profile.startDayOffset === -1 ? 700 : 400,
|
||
background: profile.startDayOffset === -1 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
|
||
color: profile.startDayOffset === -1 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
|
||
}}
|
||
>
|
||
{language === "de" ? "Gestern" : "Yesterday"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Display mode */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
|
||
{language === "de" ? "Anzeige" : "Display"}
|
||
</span>
|
||
<div style={{ display: "flex", gap: "4px" }}>
|
||
<button
|
||
onClick={() => setDarkMode(false)}
|
||
style={{
|
||
padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer",
|
||
display: "flex", alignItems: "center", gap: "4px",
|
||
fontWeight: !darkMode ? 700 : 400,
|
||
background: !darkMode ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
|
||
color: !darkMode ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
|
||
}}
|
||
>
|
||
<Sun size={12} /> {language === "de" ? "Hell" : "Light"}
|
||
</button>
|
||
<button
|
||
onClick={() => setDarkMode(true)}
|
||
style={{
|
||
padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer",
|
||
display: "flex", alignItems: "center", gap: "4px",
|
||
fontWeight: darkMode ? 700 : 400,
|
||
background: darkMode ? "#374151" : "#e5e7eb",
|
||
color: darkMode ? "#fff" : "#6b7280",
|
||
}}
|
||
>
|
||
<Moon size={12} /> {language === "de" ? "Dunkel" : "Dark"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Spacer */}
|
||
<div style={{ flex: 1 }} />
|
||
|
||
{/* Close button at bottom */}
|
||
<button
|
||
onClick={() => setShowQuickSettings(false)}
|
||
style={{
|
||
display: "flex", alignItems: "center", gap: "6px",
|
||
background: "none", border: "none", cursor: "pointer",
|
||
fontSize: "0.75rem", color: darkMode ? "#6b7280" : "#9ca3af",
|
||
padding: "4px 0",
|
||
}}
|
||
>
|
||
<PanelLeftClose size={16} />
|
||
{language === "de" ? "Ausblenden" : "Hide"}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Quick Settings toggle button (bottom-left) */}
|
||
{!showQuickSettings && (
|
||
<button
|
||
onClick={() => setShowQuickSettings(true)}
|
||
style={{
|
||
position: "fixed",
|
||
left: "8px",
|
||
bottom: "8px",
|
||
zIndex: 999,
|
||
background: darkMode ? "#1f2937" : "#f3f4f6",
|
||
border: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`,
|
||
borderRadius: "8px",
|
||
padding: "6px 8px",
|
||
cursor: "pointer",
|
||
color: darkMode ? "#9ca3af" : "#6b7280",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
fontSize: "0.75rem",
|
||
boxShadow: "0 1px 4px rgba(0,0,0,0.08)",
|
||
}}
|
||
title={language === "de" ? "Schnelleinstellungen" : "Quick Settings"}
|
||
>
|
||
<PanelLeftOpen size={14} />
|
||
</button>
|
||
)}
|
||
|
||
{/* 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("");
|
||
})(),
|
||
}}
|
||
/>
|
||
|
||
{/* Mobile Header */}
|
||
{isMobile && (
|
||
<header className="flex items-center justify-between w-full px-3 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white" style={{ minHeight: "48px" }}>
|
||
{/* Left: Navigation */}
|
||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
|
||
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToPrevWeek} title="Previous Week">
|
||
<ChevronsLeft size={16} />
|
||
</button>
|
||
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToPrevDay} title="Previous Day">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
<button className="w-7 h-7 flex items-center justify-center text-gray-600 dark:text-gray-300 rounded active:bg-white active:shadow-sm" onClick={goToToday} title="Today">
|
||
<span style={{ fontSize: "8px" }}>●</span>
|
||
</button>
|
||
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToNextDay} title="Next Day">
|
||
<ChevronLeft size={16} className="rotate-180" />
|
||
</button>
|
||
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToNextWeek} title="Next Week">
|
||
<ChevronsLeft size={16} className="rotate-180" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Center: Week info */}
|
||
<div className="flex items-center gap-1 text-sm font-semibold" style={{ color: darkMode ? "#e5e7eb" : "#333" }}>
|
||
{(isLoading || isSyncing || syncStatus === "syncing") ? (
|
||
<div className="weekly-spinner" title="Syncing..."></div>
|
||
) : syncError ? (
|
||
<AlertCircle size={14} className="text-red-500" />
|
||
) : null}
|
||
<div className="flex flex-col items-center justify-center leading-tight" style={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
||
<span>KW{getWeekNumber(getCWReferenceDate(getVisibleDays())).toString().padStart(2, "0")}</span>
|
||
<span>{getCWReferenceDate(getVisibleDays()).getFullYear()}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: Settings + Overflow */}
|
||
<div className="flex items-center gap-1" ref={mobileMenuRef}>
|
||
<button className="p-2 rounded-md text-gray-500 active:bg-gray-100" onClick={() => setShowSettings(true)} title="Settings">
|
||
<Settings size={18} />
|
||
</button>
|
||
<div className="relative">
|
||
<button className="p-2 rounded-md text-gray-500 active:bg-gray-100" onClick={() => setShowMobileMenu(!showMobileMenu)} title="More">
|
||
<MoreVertical size={18} />
|
||
</button>
|
||
{showMobileMenu && (
|
||
<div className="mobile-overflow-menu" onClick={() => setShowMobileMenu(false)}>
|
||
<button onClick={() => { setShowDatePicker(true); }}>
|
||
<Calendar size={16} /> Jump to date
|
||
</button>
|
||
<button onClick={() => setIsSearchOpen(true)}>
|
||
<Search size={16} /> Search
|
||
</button>
|
||
<div className="mobile-menu-divider" />
|
||
<button onClick={() => {
|
||
const now = new Date();
|
||
setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` });
|
||
}}>
|
||
<Plus size={16} /> Add Calendar Event
|
||
</button>
|
||
<button onClick={() => setIsRecurringTasksOpen(true)}>
|
||
<Repeat size={16} /> Recurring Tasks
|
||
</button>
|
||
<div className="mobile-menu-divider" />
|
||
<button onClick={() => { const newVal = !showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); }}>
|
||
{showNextTask ? <Play size={16} className="text-teal-600" /> : <Target size={16} />}
|
||
{showNextTask ? "Showing Next Task" : "Showing Goal"}
|
||
</button>
|
||
<button onClick={() => setShowFocusMode(true)}>
|
||
<Zap size={16} /> Focus Mode
|
||
</button>
|
||
<button onClick={() => setDarkMode(!darkMode)}>
|
||
{darkMode ? <Sun size={16} className="text-yellow-500" /> : <Moon size={16} />}
|
||
{darkMode ? "Light Mode" : "Dark Mode"}
|
||
</button>
|
||
<div className="mobile-menu-divider" />
|
||
<div className="mobile-menu-item" style={{ flexDirection: "column", alignItems: "flex-start", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.75rem", color: "#9ca3af" }}>Days to show</span>
|
||
<div className="flex items-center gap-1">
|
||
{[1, 3, 5, 7].map((num) => (
|
||
<button
|
||
key={num}
|
||
onClick={(e) => { e.stopPropagation(); setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); setShowMobileMenu(false); }}
|
||
className={`px-3 py-1 text-xs rounded ${viewDays === num ? "bg-sky-500 text-white font-bold" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||
>
|
||
{num}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{showTimeGrid && (
|
||
<div className="mobile-menu-item" style={{ flexDirection: "column", alignItems: "flex-start", gap: "6px" }}>
|
||
<span style={{ fontSize: "0.75rem", color: "#9ca3af" }}>Slot duration</span>
|
||
<div className="flex items-center gap-1">
|
||
{[15, 30, 60].map((d) => (
|
||
<button
|
||
key={d}
|
||
onClick={(e) => { e.stopPropagation(); setCellDuration(d as CellDuration); saveSetting("cellDuration", d); setShowMobileMenu(false); }}
|
||
className={`px-3 py-1 text-xs rounded ${cellDuration === d ? "bg-sky-500 text-white font-bold" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
|
||
>
|
||
{d}m
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="mobile-menu-divider" />
|
||
<button onClick={handleUndo} disabled={undoCount === 0} style={undoCount === 0 ? { opacity: 0.3 } : {}}>
|
||
<Undo2 size={16} /> Undo
|
||
</button>
|
||
<button onClick={handleRedo} disabled={redoCount === 0} style={redoCount === 0 ? { opacity: 0.3 } : {}}>
|
||
<Redo2 size={16} /> Redo
|
||
</button>
|
||
<div className="mobile-menu-divider" />
|
||
<button onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}>
|
||
<RefreshCcw size={16} /> Refresh
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
)}
|
||
|
||
{/* Desktop Header: Left, Center, Right */}
|
||
<header className="group relative 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" style={isMobile ? { display: "none" } : {}}>
|
||
{/* LEFT SECTION: Slot Duration & Days to Show */}
|
||
<div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
||
{/* Slot Duration */}
|
||
{showTimeGrid && (
|
||
<div
|
||
className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1"
|
||
title="Slot Duration"
|
||
>
|
||
<Clock size={16} className="text-gray-500 mr-1" />
|
||
{[15, 30, 60].map((duration) => (
|
||
<button
|
||
key={duration}
|
||
onClick={() => {
|
||
setCellDuration(duration as CellDuration);
|
||
saveSetting("cellDuration", duration);
|
||
}}
|
||
className={`px-2 py-0.5 text-xs rounded transition-colors ${cellDuration === duration ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
>
|
||
{duration}m
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Days to Show */}
|
||
<div
|
||
className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 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);
|
||
savedViewDaysRef.current = num;
|
||
saveSetting("viewDays", 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 dark:hover:bg-gray-700"}`}
|
||
>
|
||
{num}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Time Range */}
|
||
{showTimeGrid && (
|
||
<div
|
||
className="flex items-center gap-2 text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 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) => {
|
||
const val = Math.max(
|
||
0,
|
||
Math.min(parseInt(e.target.value) || 0, endHour - 1),
|
||
);
|
||
setStartHour(val);
|
||
saveSetting("startHour", val);
|
||
}}
|
||
className="w-10 p-0.5 border border-gray-200 dark:border-gray-700 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) => {
|
||
const val = Math.max(
|
||
startHour + 1,
|
||
Math.min(parseInt(e.target.value) || 24, 24),
|
||
);
|
||
setEndHour(val);
|
||
saveSetting("endHour", val);
|
||
}}
|
||
className="w-10 p-0.5 border border-gray-200 dark:border-gray-700 rounded text-center bg-transparent focus:outline-none focus:border-teal-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
</div>
|
||
|
||
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
|
||
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group z-10 opacity-100" style={{ pointerEvents: "auto" }}>
|
||
{/* Week & Year */}
|
||
<div className="whitespace-nowrap flex items-center gap-2">
|
||
{/* Date Picker Toggle - Moved to front */}
|
||
<div className="relative">
|
||
<button
|
||
ref={datePickerBtnRef}
|
||
className={`p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${showDatePicker ? "text-teal-600 bg-teal-50 opacity-100" : "text-gray-500 hover:text-black opacity-0 group-hover:opacity-100"}`}
|
||
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}
|
||
anchorRef={datePickerBtnRef}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Clickable Week & Year */}
|
||
<div
|
||
className="flex items-center gap-2 cursor-pointer hover:opacity-80"
|
||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||
title="Jump to date"
|
||
>
|
||
<span style={{
|
||
fontFamily: profile.cwFontFamily || "Inter",
|
||
fontSize: profile.cwFontSize || "1.125rem",
|
||
fontWeight: Number(profile.cwFontWeight || "700"),
|
||
color: adjustColorForDarkMode(profile.cwColor || "#333333", darkMode),
|
||
filter: "brightness(var(--weekly-header-brightness, 1))"
|
||
}}>
|
||
KW {getWeekNumber(getCWReferenceDate(getVisibleDays())).toString().padStart(2, "0")}
|
||
</span>
|
||
<span className="text-gray-400">|</span>
|
||
<span
|
||
style={{
|
||
fontFamily: profile.yearFontFamily || "Inter",
|
||
fontSize: profile.yearFontSize || "1.125rem",
|
||
fontWeight: Number(profile.yearFontWeight || "700"),
|
||
color: adjustColorForDarkMode(profile.yearColor || "#333333", darkMode),
|
||
filter: "brightness(var(--weekly-header-brightness, 1))"
|
||
}}
|
||
>
|
||
{currentWeekStart.getFullYear()}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Goal */}
|
||
<div className="flex items-center text-sm">
|
||
{syncError ? (
|
||
<div className="flex items-center gap-1 text-red-500 mr-2" title={syncError}>
|
||
<AlertCircle size={14} />
|
||
<span className="text-xs">{syncError}</span>
|
||
</div>
|
||
) : (isLoading || isSyncing || syncStatus === "syncing") ? (
|
||
<div className="weekly-spinner mr-2" title="Syncing..."></div>
|
||
) : (
|
||
<button
|
||
onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}
|
||
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 mr-1"
|
||
title="Refresh Calendar & Tasks"
|
||
>
|
||
<RefreshCcw size={14} />
|
||
</button>
|
||
)}
|
||
{isEditingGoal ? (
|
||
<input
|
||
type="text"
|
||
value={goal}
|
||
onChange={(e) => setGoal(e.target.value)}
|
||
onBlur={() => {
|
||
saveGoal(goal);
|
||
setIsEditingGoal(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, goal.length)}ch`,
|
||
fontFamily: profile.goalFontFamily
|
||
? `"${profile.goalFontFamily}", sans-serif`
|
||
: undefined,
|
||
fontSize: profile.goalFontSize || undefined,
|
||
fontWeight: profile.goalFontWeight || undefined,
|
||
}}
|
||
/>
|
||
) : (
|
||
<span
|
||
onClick={() => !showNextTask && setIsEditingGoal(true)}
|
||
className={`cursor-pointer font-medium italic transition-colors ${showNextTask ? "cursor-default text-gray-600 dark:text-white hover:text-black dark:hover:text-gray-100" : "text-gray-600 dark:text-yellow-400 hover:text-black dark:hover:text-yellow-300"}`}
|
||
title={showNextTask ? "Next task" : "Edit goal"}
|
||
style={{
|
||
fontFamily: profile.goalFontFamily
|
||
? `"${profile.goalFontFamily}", sans-serif`
|
||
: undefined,
|
||
fontSize: profile.goalFontSize || undefined,
|
||
fontWeight: profile.goalFontWeight || undefined,
|
||
color: adjustColorForDarkMode((profile.goalFallbackType === "quote" ? profile.taskColor : undefined) || "#333333", darkMode),
|
||
filter: "brightness(var(--weekly-goal-brightness, 1))",
|
||
maxWidth: "800px",
|
||
textAlign: "center" as const,
|
||
overflow: "hidden",
|
||
display: "-webkit-box",
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: "vertical" as const,
|
||
}}
|
||
>
|
||
{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];
|
||
const nextTaskText = nextTask ? `Do this now: ${nextTask.title}` : (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal));
|
||
return nextTaskText;
|
||
})()
|
||
: (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* RIGHT SECTION: Navigation & Tools */}
|
||
<div className="weekly-header-controls flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
||
{/* Undo/Redo */}
|
||
<button
|
||
onClick={handleUndo}
|
||
disabled={undoCount === 0}
|
||
className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default"
|
||
title="Undo (Ctrl+Z)"
|
||
>
|
||
<Undo2 size={18} className="text-gray-600 hover:text-black transition-colors" />
|
||
</button>
|
||
<button
|
||
onClick={handleRedo}
|
||
disabled={redoCount === 0}
|
||
className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default"
|
||
title="Redo (Ctrl+Y)"
|
||
>
|
||
<Redo2 size={18} className="text-gray-600 hover:text-black transition-colors" />
|
||
</button>
|
||
{/* Add Event Button */}
|
||
<button
|
||
onClick={() => {
|
||
const now = new Date();
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: undefined,
|
||
initialDate: now,
|
||
initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00`,
|
||
});
|
||
}}
|
||
className="weekly-btn-icon"
|
||
title="Add Calendar Event"
|
||
>
|
||
<Plus
|
||
size={18}
|
||
className="text-gray-600 hover:text-black transition-colors"
|
||
/>
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const newVal = !showNextTask;
|
||
setShowNextTask(newVal);
|
||
saveSetting("showNextTask", newVal);
|
||
}}
|
||
className={`weekly-btn-icon ${showNextTask ? "active" : ""}`}
|
||
title={showNextTask ? "Showing Next Task" : "Showing Goal"}
|
||
>
|
||
{showNextTask ? (
|
||
<Play size={18} className="text-teal-600" />
|
||
) : (
|
||
<Target size={18} className="text-gray-400" />
|
||
)}
|
||
</button>
|
||
|
||
{/* Focus Mode Toggle */}
|
||
<button
|
||
onClick={() => setShowFocusMode(true)}
|
||
className="weekly-btn-icon"
|
||
title="Enter Focus Mode"
|
||
>
|
||
<Zap
|
||
size={18}
|
||
className="text-gray-600 hover:text-yellow-500 transition-colors"
|
||
/>
|
||
</button>
|
||
|
||
{/* 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>
|
||
|
||
{/* 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"
|
||
>
|
||
<ChevronLeft size={16} className="rotate-180" />
|
||
</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"
|
||
>
|
||
<ChevronsLeft size={16} className="rotate-180" />
|
||
</button>
|
||
</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>
|
||
|
||
{/* Recurring Tasks */}
|
||
<button
|
||
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
||
onClick={() => setIsRecurringTasksOpen(true)}
|
||
title="Recurring Tasks"
|
||
>
|
||
<Repeat size={18} />
|
||
</button>
|
||
|
||
<button
|
||
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
||
onClick={() => setShowSettings(true)}
|
||
title="Settings"
|
||
>
|
||
<Settings size={18} />
|
||
</button>
|
||
|
||
{/* User Menu */}
|
||
<UserMenu
|
||
userEmail={session?.user?.email}
|
||
onOpenSettings={() => setShowSettings(true)}
|
||
language={profile.language}
|
||
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>
|
||
|
||
{/* All-Day Events Section (above position) */}
|
||
{allDayPosition === "above" && allDaySection}
|
||
|
||
{/* Kanban Board View */}
|
||
{viewStyle === "kanban" && (
|
||
<div className="kanban-board">
|
||
{kanbanStages.map((stage) => {
|
||
const stageTasks = tasks.filter(t => (t.kanbanStage || null) === stage.id && !t.somedayListId);
|
||
return (
|
||
<div
|
||
key={stage.id}
|
||
className="kanban-column"
|
||
onDragOver={(e) => {
|
||
if (e.dataTransfer.types.includes("text/kanban-task")) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
e.currentTarget.classList.add("kanban-column-drag-over");
|
||
}
|
||
}}
|
||
onDragLeave={(e) => {
|
||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||
}
|
||
}}
|
||
onDrop={async (e) => {
|
||
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||
const taskId = e.dataTransfer.getData("text/kanban-task");
|
||
if (taskId) {
|
||
e.preventDefault();
|
||
await updateTaskFields(taskId, { kanbanStage: stage.id });
|
||
}
|
||
}}
|
||
>
|
||
<div className="kanban-column-header" style={{ borderBottomColor: stage.color }}>
|
||
<span className="kanban-column-dot" style={{ background: stage.color }} />
|
||
<span className="kanban-column-title">{stage.name}</span>
|
||
<span className="kanban-column-count">{stageTasks.length}</span>
|
||
</div>
|
||
<div className="kanban-column-body">
|
||
{stageTasks.map(task => (
|
||
<div
|
||
key={task.id}
|
||
className={`kanban-card ${task.completed ? "kanban-card-done" : ""}`}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
e.dataTransfer.setData("text/kanban-task", task.id);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
}}
|
||
>
|
||
<div className="kanban-card-header">
|
||
<input
|
||
type="checkbox"
|
||
checked={task.completed}
|
||
onChange={() => toggleTask(task.id)}
|
||
className="kanban-card-checkbox"
|
||
/>
|
||
<span
|
||
className="kanban-card-title"
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
onBlur={(e) => {
|
||
const text = (e.target as HTMLElement).textContent || "";
|
||
if (text !== task.title) updateTask(task.id, text);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLElement).blur(); }
|
||
}}
|
||
>
|
||
{task.title}
|
||
</span>
|
||
</div>
|
||
{task.scheduledDate && (
|
||
<div className="kanban-card-date">
|
||
{new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })}
|
||
</div>
|
||
)}
|
||
{task.project && (
|
||
<div className="kanban-card-project" style={{ color: task.project.color || "#888" }}>
|
||
{task.project.icon || "📁"} {task.project.name}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{/* Unassigned column */}
|
||
{(() => {
|
||
const unassigned = tasks.filter(t => !t.kanbanStage && !t.somedayListId && !t.completed);
|
||
if (unassigned.length === 0) return null;
|
||
return (
|
||
<div
|
||
className="kanban-column kanban-column-unassigned"
|
||
onDragOver={(e) => {
|
||
if (e.dataTransfer.types.includes("text/kanban-task")) {
|
||
e.preventDefault();
|
||
e.currentTarget.classList.add("kanban-column-drag-over");
|
||
}
|
||
}}
|
||
onDragLeave={(e) => {
|
||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||
}
|
||
}}
|
||
onDrop={async (e) => {
|
||
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||
const taskId = e.dataTransfer.getData("text/kanban-task");
|
||
if (taskId) {
|
||
e.preventDefault();
|
||
await updateTaskFields(taskId, { kanbanStage: null });
|
||
}
|
||
}}
|
||
>
|
||
<div className="kanban-column-header" style={{ borderBottomColor: "#d1d5db" }}>
|
||
<span className="kanban-column-dot" style={{ background: "#d1d5db" }} />
|
||
<span className="kanban-column-title">{t.noStage}</span>
|
||
<span className="kanban-column-count">{unassigned.length}</span>
|
||
</div>
|
||
<div className="kanban-column-body">
|
||
{unassigned.map(task => (
|
||
<div
|
||
key={task.id}
|
||
className="kanban-card"
|
||
draggable
|
||
onDragStart={(e) => {
|
||
e.dataTransfer.setData("text/kanban-task", task.id);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
}}
|
||
>
|
||
<div className="kanban-card-header">
|
||
<input
|
||
type="checkbox"
|
||
checked={task.completed}
|
||
onChange={() => toggleTask(task.id)}
|
||
className="kanban-card-checkbox"
|
||
/>
|
||
<span className="kanban-card-title">{task.title}</span>
|
||
</div>
|
||
{task.scheduledDate && (
|
||
<div className="kanban-card-date">
|
||
{new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Grid with Time Column */}
|
||
{viewStyle !== "kanban" && <div className="time-grid-wrapper">
|
||
{/* Side Navigation Arrows (hover overlays) */}
|
||
<div className="side-nav side-nav-left">
|
||
<button onClick={goToPrevDay} title="Previous Day" className="side-nav-btn">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
<button onClick={goToPrevWeek} title="Previous Week" className="side-nav-btn">
|
||
<ChevronsLeft size={16} />
|
||
</button>
|
||
</div>
|
||
{/* Time Column */}
|
||
{showTimeGrid && (
|
||
<div className="time-column">
|
||
<div className="time-column-header" style={{ border: 'none', background: 'transparent' }}>
|
||
{/* Invisible structural match of day header to guarantee perfect height alignment */}
|
||
<div
|
||
style={{
|
||
visibility: "hidden", pointerEvents: "none",
|
||
display: "flex",
|
||
width: "100%",
|
||
alignItems:
|
||
activeDateLayout === "above" ||
|
||
activeDateLayout === "below"
|
||
? (profile.dateAlignment === "left" ? "flex-start" : profile.dateAlignment === "right" ? "flex-end" : "center")
|
||
: "center",
|
||
justifyContent:
|
||
profile.dateAlignment === "left"
|
||
? "flex-start"
|
||
: profile.dateAlignment === "right"
|
||
? "flex-end"
|
||
: "center",
|
||
flexDirection:
|
||
activeDateLayout === "above"
|
||
? "column-reverse"
|
||
: activeDateLayout === "below"
|
||
? "column"
|
||
: "row",
|
||
gap: profile.dateAlignment === "tight" ? "2px" : "4px",
|
||
}}
|
||
>
|
||
{activeDateLayout === "left" && (
|
||
<span className="weekly-day-date">W</span>
|
||
)}
|
||
<h3 className="weekly-day-name" style={{ marginBottom: 0 }}>
|
||
X
|
||
</h3>
|
||
{(activeDateLayout === "right" ||
|
||
activeDateLayout === "above" ||
|
||
activeDateLayout === "below" ||
|
||
activeDateLayout === undefined) && (
|
||
<span className="weekly-day-date">W</span>
|
||
)}
|
||
</div>
|
||
</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";
|
||
if (!isHourStart && !showSubHourSlots) return (
|
||
<div
|
||
key={slot}
|
||
className="time-slot-label"
|
||
style={{ height: `${getSlotHeight(cellDuration)}px` }}
|
||
/>
|
||
);
|
||
return (
|
||
<div
|
||
key={slot}
|
||
className={`time-slot-label ${isHourStart ? "hour-start" : "sub-hour"}`}
|
||
style={{ height: `${getSlotHeight(cellDuration)}px` }}
|
||
>
|
||
{isHourStart && <span>{formatHour(hour, hourLabelFormat, timeFormat)}</span>}
|
||
{!isHourStart && showSubHourSlots && <span className="sub-hour-label">:{minutes}</span>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Day Columns */}
|
||
<main
|
||
ref={gridRef}
|
||
className={`weekly-days-grid cols-${viewDays}`}
|
||
data-slide-direction={slideDirection}
|
||
data-nav-type={viewDays > 1 ? "week" : "day"}
|
||
>
|
||
{getVisibleDays().map((date, colIndex) => {
|
||
const todayMidnight = new Date();
|
||
todayMidnight.setHours(0, 0, 0, 0);
|
||
const isToday = isSameDay(date, todayMidnight);
|
||
const isPast = date < todayMidnight && !isToday;
|
||
|
||
return (
|
||
<div
|
||
key={date.toISOString()}
|
||
className={`weekly-day-column ${date.getDay() === 6 ? "is-sat" : ""} ${date.getDay() === 0 ? "is-sun" : ""} ${isToday ? "is-today" : ""} ${isPast ? "is-past" : ""}`}
|
||
>
|
||
{/* Day Header */}
|
||
<header className="weekly-day-header" ref={colIndex === 0 ? dayHeaderRef : undefined}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
width: "100%",
|
||
alignItems:
|
||
activeDateLayout === "above" ||
|
||
activeDateLayout === "below"
|
||
? (profile.dateAlignment === "left" ? "flex-start" : profile.dateAlignment === "right" ? "flex-end" : "center")
|
||
: (profile.dateVerticalAlign === "top" ? "flex-start" : profile.dateVerticalAlign === "bottom" ? "flex-end" : "center"),
|
||
justifyContent:
|
||
profile.dateAlignment === "left"
|
||
? "flex-start"
|
||
: profile.dateAlignment === "right"
|
||
? "flex-end"
|
||
: "center",
|
||
flexDirection:
|
||
activeDateLayout === "above"
|
||
? "column-reverse"
|
||
: activeDateLayout === "below"
|
||
? "column"
|
||
: "row",
|
||
gap: profile.dateAlignment === "tight" ? "2px" : (profile.dayHeaderGap || "0.35em"),
|
||
}}
|
||
>
|
||
{activeDateLayout === "left" && (
|
||
<span className="weekly-day-date" style={{ flexShrink: 0 }}>
|
||
{formatDateHeader(date, language)}
|
||
</span>
|
||
)}
|
||
<h3
|
||
className={`weekly-day-name ${isSameDay(date, new Date()) ? "is-today" : ""}`}
|
||
style={{ marginBottom: 0, flexShrink: 0 }}
|
||
>
|
||
{getDayName(date, language, weekdayFormat, customWeekdayNames, weekStartDay, weekdayCase)}
|
||
</h3>
|
||
{(activeDateLayout === "right" ||
|
||
activeDateLayout === "above" ||
|
||
activeDateLayout === "below" ||
|
||
activeDateLayout === undefined) && (
|
||
<span className="weekly-day-date" style={{ flexShrink: 0 }}>
|
||
{formatDateHeader(date, language)}
|
||
</span>
|
||
)}
|
||
</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" }}
|
||
>
|
||
{/* Calendar Fetching Indicator */}
|
||
{showTimeGrid && colIndex === 0 && isFetchingCalendar && (
|
||
<div className="absolute top-2 left-2 z-[60] flex items-center gap-2 bg-white/90 dark:bg-zinc-800/90 px-3 py-1.5 rounded-full shadow-sm border border-zinc-200 dark:border-zinc-700 text-xs text-zinc-600 dark:text-zinc-300 pointer-events-none">
|
||
<div className="w-3 h-3 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin"></div>
|
||
Syncing Calendar...
|
||
</div>
|
||
)}
|
||
|
||
{/* 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;
|
||
const timeString = `${String(nowHour).padStart(2, "0")}:${String(nowMinute).padStart(2, "0")}`;
|
||
return (
|
||
<div
|
||
className="now-line"
|
||
data-time={timeString}
|
||
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 calculatedHeight =
|
||
durationMinutes * pixelsPerMinute;
|
||
// Ensure minimum height of 15px for visibility
|
||
const height = Math.max(calculatedHeight, 15);
|
||
|
||
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>
|
||
);
|
||
})}
|
||
{/* GridTaskBlocks for timed tasks */}
|
||
{getTasksForDate(date)
|
||
.filter(t => !!t.startTime)
|
||
.map(task => (
|
||
<GridTaskBlock
|
||
key={task.id}
|
||
task={task}
|
||
date={date}
|
||
activeDate={currentWeekStart}
|
||
cellDuration={cellDuration}
|
||
darkMode={darkMode}
|
||
isProtected={false}
|
||
editingTaskId={editingTaskId}
|
||
setEditingTaskId={setEditingTaskId}
|
||
updateTask={updateTask}
|
||
updateTaskNotes={updateTaskNotes}
|
||
updateTaskDuration={updateTaskDuration}
|
||
toggleTask={toggleTask}
|
||
deleteTask={deleteTask}
|
||
toggleTaskRolling={toggleTaskRolling}
|
||
setSelectedTaskForNotes={setSelectedTaskForNotes}
|
||
setSelectedTaskForRecurrence={setSelectedTaskForRecurrence}
|
||
handleDragStart={handleDragStart}
|
||
handleDragEnd={handleDragEnd}
|
||
getSlotHeight={getSlotHeight}
|
||
draggedTask={draggedTask as any}
|
||
addSubTask={addSubTask}
|
||
toggleSubTask={toggleSubTask}
|
||
updateSubTask={updateSubTask}
|
||
deleteSubTask={deleteSubTask}
|
||
onSetEditingTaskId={setEditingTaskId}
|
||
workingHoursStart={workingHoursStart}
|
||
showTaskCheckboxes={profile.showTaskCheckboxes}
|
||
projects={projects}
|
||
onProjectAssign={assignProject}
|
||
/>
|
||
))}
|
||
{visibleSlots.map((slot) => {
|
||
const hour = getHourFromSlot(slot);
|
||
const minutes = slot.split(":")[1];
|
||
const isHourStart = minutes === "00";
|
||
const slotEvents = getEventsForSlot(date, slot);
|
||
const isActive =
|
||
activeSlot?.day === date.getDay() &&
|
||
activeSlot?.slot === slot;
|
||
const isProtected = isSlotProtected(date, slot);
|
||
const isOccupiedByTask = isSlotOccupiedByTask(date, slot, draggedTask?.id);
|
||
const isOccupiedByAnyTask = isSlotOccupiedByTask(date, slot);
|
||
|
||
const handleSlotClick = (e: React.MouseEvent) => {
|
||
if (isProtected || isOccupiedByAnyTask) return; // Don't allow adding tasks to protected or occupied 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 || isOccupiedByTask) return; // Don't allow dropping on protected or occupied 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 && !isOccupiedByTask ? "drop-target" : ""} ${isActive ? "active" : ""}`}
|
||
style={{
|
||
height: `${getSlotHeight(cellDuration)}px`,
|
||
position: "relative",
|
||
cursor: isProtected || isOccupiedByAnyTask ? "not-allowed" : "text",
|
||
}}
|
||
onClick={handleSlotClick}
|
||
onDragOver={(e) =>
|
||
!isProtected && !isOccupiedByTask &&
|
||
handleDragOver(e, date.getDay(), slot)
|
||
}
|
||
onDrop={handleSlotDrop}
|
||
>
|
||
{/* Drop preview indicator */}
|
||
{isDropTarget && !isProtected && !isOccupiedByTask && (
|
||
<div className="drop-preview" />
|
||
)}
|
||
{/* 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: `${Math.max(eventHeight, 15)}px`,
|
||
minHeight: `15px`,
|
||
position: "absolute",
|
||
top: `${topOffset}px`,
|
||
left: "-10px",
|
||
right: "-15px",
|
||
zIndex: 1,
|
||
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 (e) => {
|
||
// Prevent double submission if form was submitted
|
||
if (activeSlot && newSlotTask.trim()) {
|
||
// Delay slightly to let onSubmit fire if that was the cause
|
||
setTimeout(async () => {
|
||
if (activeSlot && newSlotTask.trim()) {
|
||
const taskTitle = newSlotTask.trim();
|
||
setActiveSlot(null);
|
||
setNewSlotTask("");
|
||
await addTask(date, taskTitle, slot);
|
||
}
|
||
}, 100);
|
||
} else {
|
||
setActiveSlot(null);
|
||
setNewSlotTask("");
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
setActiveSlot(null);
|
||
setNewSlotTask("");
|
||
}
|
||
}}
|
||
autoFocus
|
||
className="weekly-task-input"
|
||
style={{
|
||
width: "100%",
|
||
background: "transparent",
|
||
outline: "none",
|
||
minHeight: "24px",
|
||
paddingLeft: "0",
|
||
}}
|
||
/>
|
||
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{/* All Day Events Section */}
|
||
|
||
|
||
</div>
|
||
) : (
|
||
<div
|
||
onDragOver={(e) => handleDragOver(e, date.getDay())}
|
||
onDrop={(e) => handleDrop(e, date.getDay())}
|
||
onDragLeave={handleDragLeave}
|
||
style={{ flex: 1 }}
|
||
>
|
||
{/* 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={() => setSelectedTaskForNotes(task)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() =>
|
||
setSelectedTaskForRecurrence(task)
|
||
}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
onAddSubTask={addSubTask}
|
||
onToggleSubTask={toggleSubTask}
|
||
onDeleteSubTask={deleteSubTask}
|
||
onUpdateSubTask={updateSubTask}
|
||
editingTaskId={editingTaskId}
|
||
onSetEditingTaskId={setEditingTaskId}
|
||
showTaskCheckboxes={profile.showTaskCheckboxes}
|
||
projects={projects}
|
||
onProjectAssign={assignProject}
|
||
kanbanStages={kanbanStages}
|
||
/>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</main>
|
||
|
||
{/* Right Navigation Arrows (after grid so it paints on top) */}
|
||
<div className="side-nav side-nav-right">
|
||
<button onClick={goToNextDay} title="Next Day" className="side-nav-btn">
|
||
<ChevronLeft size={16} className="rotate-180" />
|
||
</button>
|
||
<button onClick={goToNextWeek} title="Next Week" className="side-nav-btn">
|
||
<ChevronsLeft size={16} className="rotate-180" />
|
||
</button>
|
||
</div>
|
||
</div>}
|
||
|
||
{/* All-Day Events Section (below position) */}
|
||
{allDayPosition === "below" && allDaySection}
|
||
|
||
{/* Someday Section */}
|
||
{showSomeday && (
|
||
<section
|
||
ref={somedaySectionRefCb}
|
||
className={`weekly-someday ${somedayExpanded ? "expanded" : "collapsed"} transition-colors duration-200`}
|
||
>
|
||
{/* Someday tabs bar */}
|
||
<div className="someday-tabs-bar">
|
||
<div
|
||
onClick={() => setSomedayExpanded(!somedayExpanded)}
|
||
style={{
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
marginRight: "4px",
|
||
}}
|
||
title={somedayExpanded ? "Collapse" : "Expand"}
|
||
>
|
||
<ChevronRight
|
||
size={14}
|
||
style={{
|
||
transform: somedayExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||
transition: "transform 0.15s",
|
||
color: "var(--weekly-text-light)",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<span style={{
|
||
fontSize: "0.7rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-text-light)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
whiteSpace: "nowrap",
|
||
}}>
|
||
any day
|
||
</span>
|
||
</div>
|
||
<button
|
||
className="someday-bar-icon-btn"
|
||
onClick={() => handleStartAddSomedayList()}
|
||
title={t.newList}
|
||
>
|
||
<ListPlus size={14} />
|
||
</button>
|
||
<button
|
||
className="someday-bar-icon-btn"
|
||
onClick={() => { setCreatingNewTab(true); setCreatingNewTabName(""); }}
|
||
title={t.newTab}
|
||
>
|
||
<FolderPlus size={14} />
|
||
</button>
|
||
{creatingNewTab && (
|
||
<input
|
||
className="someday-tab-rename-input"
|
||
value={creatingNewTabName}
|
||
onChange={(e) => setCreatingNewTabName(e.target.value)}
|
||
onBlur={() => {
|
||
const name = creatingNewTabName.trim();
|
||
if (name && !somedayTabs.includes(name)) {
|
||
saveCustomTabs([...customTabs, name]);
|
||
setSomedayTab(name);
|
||
} else if (name) {
|
||
setSomedayTab(name);
|
||
}
|
||
setCreatingNewTab(false);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") e.currentTarget.blur();
|
||
if (e.key === "Escape") setCreatingNewTab(false);
|
||
}}
|
||
placeholder={t.newTab}
|
||
autoFocus
|
||
style={{
|
||
fontSize: "0.75rem",
|
||
border: "1px solid var(--weekly-border)",
|
||
borderRadius: "3px",
|
||
padding: "2px 6px",
|
||
background: "var(--weekly-bg)",
|
||
color: "var(--weekly-text)",
|
||
width: "80px",
|
||
}}
|
||
/>
|
||
)}
|
||
<div className="someday-tabs-bar-divider" />
|
||
<button
|
||
className={`someday-tab-btn-h ${activeSomedayTab === null ? "active" : ""} ${dragOverTab === "__all__" ? "drag-over" : ""}`}
|
||
onClick={() => setSomedayTab(null)}
|
||
onDragOver={(e) => {
|
||
if (e.dataTransfer.types.includes("text/list-id")) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
setDragOverTab("__all__");
|
||
}
|
||
}}
|
||
onDragLeave={() => setDragOverTab(null)}
|
||
onDrop={(e) => {
|
||
const listId = e.dataTransfer.getData("text/list-id");
|
||
if (listId) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
assignListToTab(listId, null);
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
}
|
||
setDragOverTab(null);
|
||
}}
|
||
>{t.allTabs} <span className="someday-tab-count">{somedayLists.length}</span></button>
|
||
{somedayTabs.map(tab => (
|
||
editingTabName === tab ? (
|
||
<input
|
||
key={tab}
|
||
className="someday-tab-rename-input"
|
||
value={renamingTabValue}
|
||
onChange={(e) => setRenamingTabValue(e.target.value)}
|
||
onBlur={() => {
|
||
renameTab(tab, renamingTabValue);
|
||
setEditingTabName(null);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") e.currentTarget.blur();
|
||
if (e.key === "Escape") setEditingTabName(null);
|
||
}}
|
||
autoFocus
|
||
style={{
|
||
fontSize: "0.75rem",
|
||
border: "1px solid var(--weekly-border)",
|
||
borderRadius: "3px",
|
||
padding: "2px 6px",
|
||
background: "var(--weekly-bg)",
|
||
color: "var(--weekly-text)",
|
||
width: "80px",
|
||
}}
|
||
/>
|
||
) : (
|
||
<div
|
||
key={tab}
|
||
className="someday-tab-wrapper-h"
|
||
onDragOver={(e) => {
|
||
if (e.dataTransfer.types.includes("text/list-id")) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
setDragOverTab(tab);
|
||
}
|
||
}}
|
||
onDragLeave={() => setDragOverTab(null)}
|
||
onDrop={(e) => {
|
||
const listId = e.dataTransfer.getData("text/list-id");
|
||
if (listId) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
assignListToTab(listId, tab);
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
}
|
||
setDragOverTab(null);
|
||
}}
|
||
>
|
||
<button
|
||
className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""} ${dragOverTab === tab ? "drag-over" : ""}`}
|
||
onClick={() => setSomedayTab(tab)}
|
||
onDoubleClick={() => {
|
||
setEditingTabName(tab);
|
||
setRenamingTabValue(tab);
|
||
}}
|
||
title={t.renameTab}
|
||
>{tab} <span className="someday-tab-count">{somedayLists.filter(l => l.tab === tab).length}</span></button>
|
||
<button
|
||
className="someday-tab-dissolve-h"
|
||
onClick={(e) => { e.stopPropagation(); dissolveTab(tab); }}
|
||
title={t.dissolveTab}
|
||
><X size={10} /></button>
|
||
</div>
|
||
)
|
||
))}
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "row", maxWidth: "100%", width: "100%" }}>
|
||
<div ref={somedayGridRef} style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
|
||
{somedayExpanded && (
|
||
<div
|
||
className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}
|
||
style={{ display: "flex", flexDirection: "row", flexWrap: "nowrap" }}
|
||
onDragLeave={(e) => {
|
||
// Clear indicator when leaving the someday grid entirely
|
||
if (draggingListId && !e.currentTarget.contains(e.relatedTarget as Node)) {
|
||
setDropTargetListIndex(null);
|
||
}
|
||
}}
|
||
>
|
||
{(() => {
|
||
const baseLists = filteredSomedayLists.length > 0
|
||
? filteredSomedayLists
|
||
: [{ id: "default", title: "LISTE", tasks: [] as Task[] }];
|
||
return baseLists.slice(0, Math.max(filteredSomedayLists.length, viewDays));
|
||
})()
|
||
.flatMap((list, listIdx, arr) => {
|
||
const indicator = draggingListId && dropTargetListIndex === listIdx && draggingListId !== list.id ? (
|
||
<div key={`drop-indicator-${listIdx}`} style={{
|
||
width: "3px",
|
||
flexShrink: 0,
|
||
background: "#6366f1",
|
||
borderRadius: "2px",
|
||
alignSelf: "stretch",
|
||
transition: "opacity 0.15s",
|
||
}} />
|
||
) : null;
|
||
// After last item, check for drop at end
|
||
const endIndicator = listIdx === arr.length - 1 && draggingListId && dropTargetListIndex === arr.length && draggingListId !== list.id ? (
|
||
<div key="drop-indicator-end" style={{
|
||
width: "3px",
|
||
flexShrink: 0,
|
||
background: "#6366f1",
|
||
borderRadius: "2px",
|
||
alignSelf: "stretch",
|
||
transition: "opacity 0.15s",
|
||
}} />
|
||
) : null;
|
||
const listEl = (
|
||
<div
|
||
key={list.id}
|
||
className={`weekly-someday-list ${draggingListId === list.id ? "is-dragging" : ""} p-2 transition-colors duration-200`}
|
||
style={{
|
||
minHeight: "200px",
|
||
cursor: "text",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
onMouseDown={(e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (
|
||
target.closest(".task-list-slot") ||
|
||
target.closest(".someday-list-header") ||
|
||
target.closest("button") ||
|
||
target.tagName === "INPUT"
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const input = e.currentTarget.querySelector(
|
||
`[data-someday-add-input="${list.id}"]`,
|
||
) as HTMLInputElement;
|
||
if (input) {
|
||
input.focus();
|
||
}
|
||
}}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest(".weekly-task-item")) {
|
||
return;
|
||
}
|
||
if (!isDragFromHandle.current) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
isDragFromHandle.current = false;
|
||
setDraggingListId(list.id);
|
||
e.dataTransfer.setData("text/list-id", list.id);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
}}
|
||
onDragEnd={() => { setDraggingListId(null); setDropTargetListIndex(null); setDragOverTab(null); }}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
if (!draggingListId || draggingListId === list.id) return;
|
||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||
const midX = rect.left + rect.width / 2;
|
||
// Drop before or after this list
|
||
if (e.clientX < midX) {
|
||
setDropTargetListIndex(listIdx);
|
||
} else {
|
||
setDropTargetListIndex(listIdx + 1);
|
||
}
|
||
}}
|
||
onDrop={async (e) => {
|
||
e.preventDefault();
|
||
const droppedListId =
|
||
e.dataTransfer.getData("text/list-id");
|
||
const draggedTaskId =
|
||
e.dataTransfer.getData("text/plain");
|
||
|
||
if (droppedListId === list.id && !draggedTaskId) {
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
return;
|
||
}
|
||
|
||
// If a task is dropped on the list generally (not on a specific slot),
|
||
// find the first free slot and place it there.
|
||
if (draggedTaskId && draggedTask && draggedTask.id === draggedTaskId) {
|
||
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
|
||
let nextFreeSlot = 0;
|
||
while (occupiedSlots.includes(nextFreeSlot)) {
|
||
nextFreeSlot++;
|
||
}
|
||
handleSomedayDrop(e, list.id, nextFreeSlot);
|
||
return;
|
||
}
|
||
|
||
// Reorder logic (list drag) - apply the visual order
|
||
if (!droppedListId || dropTargetListIndex === null) {
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
return;
|
||
}
|
||
const dragIdx = somedayLists.findIndex(
|
||
(l) => l.id === droppedListId,
|
||
);
|
||
if (dragIdx === -1) {
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
return;
|
||
}
|
||
|
||
const newLists = [...somedayLists];
|
||
const [moved] = newLists.splice(dragIdx, 1);
|
||
// Adjust target index since we removed an item before it
|
||
const insertIdx = dragIdx < dropTargetListIndex
|
||
? dropTargetListIndex - 1
|
||
: dropTargetListIndex;
|
||
newLists.splice(insertIdx, 0, moved);
|
||
|
||
setSomedayLists(newLists);
|
||
setDraggingListId(null);
|
||
setDropTargetListIndex(null);
|
||
|
||
// 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: "flex-start",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
{listToDelete === list.id ? (
|
||
<div style={{ display: "flex", flexDirection: "column", width: "100%", gap: "8px", padding: "4px" }}>
|
||
<span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>Delete this list?</span>
|
||
{list.externalProvider && <span style={{ fontSize: "0.75rem", color: "#888" }}>Note: This list is not deleted from {list.externalProvider}, just from this view.</span>}
|
||
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
|
||
<button onClick={(e) => {
|
||
e.stopPropagation();
|
||
setListToDelete(null);
|
||
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Cancel</button>
|
||
<button onClick={async (e) => {
|
||
e.stopPropagation();
|
||
try {
|
||
await fetch(`/api/someday-lists?id=${list.id}`, { method: "DELETE" });
|
||
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
|
||
setListToDelete(null);
|
||
} catch (err) { console.error(err); }
|
||
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Delete</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div
|
||
className="someday-drag-handle"
|
||
title="Drag to reorder"
|
||
onMouseDown={() => { isDragFromHandle.current = true; }}
|
||
onMouseUp={() => { isDragFromHandle.current = false; }}
|
||
>
|
||
<GripVertical size={14} />
|
||
</div>
|
||
<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();
|
||
}}
|
||
/>
|
||
{list.externalProvider && (() => {
|
||
const providerUrl = list.externalProvider === "google" ? "https://tasks.google.com/tasks/" :
|
||
list.externalProvider === "outlook" ? "https://to-do.live.com/tasks/" :
|
||
list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null;
|
||
const iconContent = (
|
||
<span
|
||
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider}`}
|
||
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }}
|
||
>
|
||
{list.externalProvider === "outlook" ? (
|
||
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#0078D4] dark:hover:text-[#00A4EF] transition-colors" />
|
||
) : list.externalProvider === "google" ? (
|
||
<FontAwesomeIcon icon={faGoogle} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#4285F4] dark:hover:text-[#8AB4F8] transition-colors" />
|
||
) : list.externalProvider === "apple" ? (
|
||
<FontAwesomeIcon icon={faApple} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#555] dark:hover:text-[#ccc] transition-colors" />
|
||
) : list.externalProvider === "synology" ? (
|
||
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" />
|
||
) : (
|
||
<RefreshCcw size={14} className="text-zinc-400" />
|
||
)}
|
||
</span>
|
||
);
|
||
return providerUrl ? (
|
||
<a href={providerUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||
{iconContent}
|
||
</a>
|
||
) : iconContent;
|
||
})()}
|
||
<div className="someday-tab-assign" style={{ marginLeft: "auto", position: "relative", display: "flex", alignItems: "center" }}>
|
||
<FolderPlus size={13} style={{ color: list.tab ? "var(--weekly-accent, #6366f1)" : "#bbb", flexShrink: 0 }} />
|
||
<select
|
||
className="someday-tab-select"
|
||
value={list.tab || ""}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
if (val === "__new__") {
|
||
e.target.value = list.tab || "";
|
||
setNewTabForListId(list.id);
|
||
setNewTabNameValue("");
|
||
} else {
|
||
assignListToTab(list.id, val || null);
|
||
}
|
||
}}
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
opacity: 0,
|
||
cursor: "pointer",
|
||
width: "100%",
|
||
}}
|
||
title={t.assignTab || "Assign to tab"}
|
||
>
|
||
<option value="">{t.noTab}</option>
|
||
{somedayTabs.map(tab => (
|
||
<option key={tab} value={tab}>{tab}</option>
|
||
))}
|
||
<option value="__new__">+ {t.newTab || "New tab"}</option>
|
||
</select>
|
||
</div>
|
||
{newTabForListId === list.id && (
|
||
<input
|
||
className="someday-tab-new-input"
|
||
value={newTabNameValue}
|
||
onChange={(e) => setNewTabNameValue(e.target.value)}
|
||
onBlur={() => {
|
||
if (newTabNameValue.trim()) {
|
||
assignListToTab(list.id, newTabNameValue.trim());
|
||
}
|
||
setNewTabForListId(null);
|
||
setNewTabNameValue("");
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") e.currentTarget.blur();
|
||
if (e.key === "Escape") {
|
||
setNewTabForListId(null);
|
||
setNewTabNameValue("");
|
||
}
|
||
}}
|
||
placeholder={t.newTabName || "Tab name..."}
|
||
autoFocus
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
fontSize: "0.7rem",
|
||
border: "1px solid var(--weekly-border)",
|
||
borderRadius: "4px",
|
||
padding: "2px 6px",
|
||
background: "var(--weekly-bg)",
|
||
color: "var(--weekly-text)",
|
||
width: "80px",
|
||
outline: "none",
|
||
}}
|
||
/>
|
||
)}
|
||
<button
|
||
className="someday-list-delete-btn"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setListToDelete(list.id);
|
||
}}
|
||
style={{
|
||
border: "none",
|
||
background: "none",
|
||
cursor: "pointer",
|
||
color: "#ccc",
|
||
marginLeft: "4px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: "4px"
|
||
}}
|
||
title="Delete List"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className="weekly-task-list"
|
||
style={{
|
||
flex: 1,
|
||
flexDirection: "column",
|
||
justifyContent: "flex-start",
|
||
position: "relative",
|
||
display: "flex",
|
||
}}
|
||
>
|
||
{(() => {
|
||
const slotCount = getSomedaySlotCount(list.tasks);
|
||
const indexedTasks = list.tasks.filter(t => t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined && t.somedaySlotIndex < slotCount);
|
||
const unindexedTasks = list.tasks.filter(t => t.somedaySlotIndex === null || t.somedaySlotIndex === undefined || t.somedaySlotIndex >= slotCount);
|
||
|
||
// Fill indexed slots and put unindexed tasks in empty slots starting from top
|
||
const slots = Array.from({ length: slotCount }, (_, i) => ({ index: i, task: indexedTasks.find(t => t.somedaySlotIndex === i) || null }));
|
||
|
||
let unindexedIdx = 0;
|
||
const finalSlots = slots.map(slot => {
|
||
if (!slot.task && unindexedIdx < unindexedTasks.length) {
|
||
return { ...slot, task: unindexedTasks[unindexedIdx++] };
|
||
}
|
||
return slot;
|
||
});
|
||
|
||
// Any remaining unindexed tasks that didn't fit in slots
|
||
const remainingTasks = unindexedTasks.slice(unindexedIdx);
|
||
|
||
return (
|
||
<>
|
||
<SomedayAddTask
|
||
listId={list.id}
|
||
onAdd={async (title) => {
|
||
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
|
||
let nextFreeSlot = 0;
|
||
while (occupiedSlots.includes(nextFreeSlot)) {
|
||
nextFreeSlot++;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch("/api/tasks", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
title,
|
||
somedayListId: list.id,
|
||
somedaySlotIndex: nextFreeSlot
|
||
}),
|
||
});
|
||
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);
|
||
}
|
||
}}
|
||
/>
|
||
{finalSlots.map((slot) => {
|
||
const isTarget = dropPreview?.listId === list.id && dropPreview?.slotIdx === slot.index;
|
||
const task = slot.task;
|
||
return (
|
||
<div
|
||
key={slot.index}
|
||
className={`task-list-slot ${isTarget ? 'drop-target' : ''}`}
|
||
onDragOver={(e) => handleSomedayDragOver(e, list.id, slot.index)}
|
||
onDrop={(e) => handleSomedayDrop(e, list.id, slot.index)}
|
||
onDragLeave={() => setDropPreview(null)}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
if (!task) {
|
||
setActiveAddSlot({ listId: list.id, slotIdx: slot.index });
|
||
}
|
||
}}
|
||
style={{ minHeight: "24px", cursor: task ? "default" : "text" }}
|
||
>
|
||
{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}
|
||
onAddSubTask={addSubTask}
|
||
onToggleSubTask={toggleSubTask}
|
||
onDeleteSubTask={deleteSubTask}
|
||
onUpdateSubTask={updateSubTask}
|
||
editingTaskId={editingTaskId}
|
||
onSetEditingTaskId={setEditingTaskId}
|
||
showTaskCheckboxes={profile.showTaskCheckboxes}
|
||
projects={projects}
|
||
onProjectAssign={assignProject}
|
||
kanbanStages={kanbanStages}
|
||
/>
|
||
) : (
|
||
activeAddSlot?.listId === list.id && activeAddSlot?.slotIdx === slot.index && (
|
||
<SomedayAddTask
|
||
listId={list.id}
|
||
slotIdx={slot.index}
|
||
onAdd={async (title) => {
|
||
try {
|
||
const res = await fetch("/api/tasks", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
title,
|
||
somedayListId: list.id,
|
||
somedaySlotIndex: slot.index
|
||
}),
|
||
});
|
||
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);
|
||
}
|
||
setActiveAddSlot(null);
|
||
}}
|
||
onCancel={() => setActiveAddSlot(null)}
|
||
/>
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{remainingTasks.map((task) => (
|
||
<div key={task.id} className="task-list-slot">
|
||
<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}
|
||
onAddSubTask={addSubTask}
|
||
onToggleSubTask={toggleSubTask}
|
||
onDeleteSubTask={deleteSubTask}
|
||
onUpdateSubTask={updateSubTask}
|
||
editingTaskId={editingTaskId}
|
||
onSetEditingTaskId={setEditingTaskId}
|
||
showTaskCheckboxes={profile.showTaskCheckboxes}
|
||
projects={projects}
|
||
onProjectAssign={assignProject}
|
||
kanbanStages={kanbanStages}
|
||
/>
|
||
</div>
|
||
))}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
);
|
||
return [indicator, listEl, endIndicator].filter(Boolean);
|
||
})}
|
||
|
||
{/* 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 px-4"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setIsAddingSomedayList(false);
|
||
setSelectedSomedayProvider(null);
|
||
}}
|
||
>
|
||
<div
|
||
className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-2xl w-full max-w-md"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h3 className="text-xl font-black mb-1 flex items-center gap-2">
|
||
<Plus className="w-6 h-6" />
|
||
NEW LIST
|
||
</h3>
|
||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-6 uppercase tracking-widest font-bold">
|
||
Create a new section for your tasks
|
||
</p>
|
||
|
||
<div className="mb-6">
|
||
<label className="block text-xs font-black text-zinc-400 dark:text-zinc-500 mb-2 uppercase tracking-tighter">
|
||
List Title
|
||
</label>
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
placeholder="NAME YOUR LIST..."
|
||
value={newSomedayListName}
|
||
onChange={(e) =>
|
||
setNewSomedayListName(e.target.value)
|
||
}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") saveSomedayList();
|
||
if (e.key === "Escape") {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName("");
|
||
setSelectedSomedayProvider(null);
|
||
}
|
||
}}
|
||
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800/50 border-2 border-zinc-100 dark:border-zinc-800 rounded-xl focus:border-zinc-900 dark:focus:border-zinc-100 outline-none transition-all font-bold text-lg"
|
||
style={{
|
||
fontFamily: profile.taskFontFamily
|
||
? `"${profile.taskFontFamily}"`
|
||
: "inherit",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mb-8">
|
||
<label className="block text-xs font-black text-zinc-400 dark:text-zinc-500 mb-3 uppercase tracking-tighter">
|
||
Sync with External Provider (Optional)
|
||
</label>
|
||
<div
|
||
className="grid gap-2"
|
||
style={{ gridTemplateColumns: `repeat(${1 + (connections?.some(c => c.provider === "google") ? 1 : 0) + (connections?.some(c => c.provider === "outlook") ? 1 : 0) + (connections?.some(c => c.provider === "synology") ? 1 : 0)}, minmax(0, 1fr))` }}
|
||
>
|
||
<button
|
||
onClick={() => setSelectedSomedayProvider(null)}
|
||
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${!selectedSomedayProvider ? "border-zinc-900 bg-zinc-900 text-white" : "border-zinc-100 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600"}`}
|
||
>
|
||
<div className="w-6 h-6 flex items-center justify-center mb-1">
|
||
<Layout size={18} />
|
||
</div>
|
||
<span className="text-[10px] font-black uppercase">
|
||
Local
|
||
</span>
|
||
</button>
|
||
{connections?.some(c => c.provider === "google") && (
|
||
<button
|
||
onClick={() =>
|
||
setSelectedSomedayProvider("google")
|
||
}
|
||
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "google" ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-200 dark:hover:border-blue-800/40"}`}
|
||
>
|
||
<div className="w-6 h-6 flex items-center justify-center mb-1">
|
||
<FontAwesomeIcon icon={faGoogle} className="w-4 h-4 text-[#4285F4]" />
|
||
</div>
|
||
<span className="text-[10px] font-black uppercase">
|
||
Google
|
||
</span>
|
||
</button>
|
||
)}
|
||
{connections?.some(c => c.provider === "outlook") && (
|
||
<button
|
||
onClick={() =>
|
||
setSelectedSomedayProvider("outlook")
|
||
}
|
||
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "outlook" ? "border-blue-600 bg-blue-100 dark:bg-blue-900/30" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-300 dark:hover:border-blue-800/50"}`}
|
||
>
|
||
<div className="w-6 h-6 flex items-center justify-center mb-1">
|
||
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-[#00A4EF]" />
|
||
</div>
|
||
<span className="text-[10px] font-black uppercase">
|
||
Outlook
|
||
</span>
|
||
</button>
|
||
)}
|
||
{connections?.some(c => c.provider === "synology") && (
|
||
<button
|
||
onClick={() =>
|
||
setSelectedSomedayProvider("synology")
|
||
}
|
||
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "synology" ? "border-zinc-900 bg-zinc-50 dark:bg-zinc-800" : "border-zinc-100 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600"}`}
|
||
>
|
||
<div className="w-6 h-6 flex items-center justify-center mb-1">
|
||
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-500" />
|
||
</div>
|
||
<span className="text-[10px] font-black uppercase">
|
||
Synology
|
||
</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName("");
|
||
setSelectedSomedayProvider(null);
|
||
}}
|
||
className="flex-1 px-4 py-4 text-sm font-black text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-xl transition-all uppercase tracking-widest"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={saveSomedayList}
|
||
disabled={!newSomedayListName.trim()}
|
||
className="flex-[2] px-4 py-4 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 font-black rounded-xl hover:opacity-90 transition-all uppercase tracking-widest shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Create List
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* close flex row */}
|
||
</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}
|
||
onStopRecurring={async (task) => {
|
||
const idToUpdate = task.id.startsWith("virtual-")
|
||
? task.id.split("-")[1]
|
||
: task.id;
|
||
const dateStr = new Date().toISOString();
|
||
|
||
// Update locally
|
||
setTasks((prev) => {
|
||
const newTasks = [];
|
||
for (const t of prev) {
|
||
const isMatch =
|
||
t.title === task.title &&
|
||
t.userId === task.userId &&
|
||
t.recurrenceInterval === task.recurrenceInterval &&
|
||
t.recurrenceUnit === task.recurrenceUnit;
|
||
|
||
if (isMatch) {
|
||
// Remove future occurrences from the UI
|
||
if (t.scheduledDate && new Date(t.scheduledDate) > new Date(dateStr)) {
|
||
continue;
|
||
}
|
||
newTasks.push({ ...t, recurrenceEndDate: new Date(dateStr) });
|
||
} else {
|
||
newTasks.push(t);
|
||
}
|
||
}
|
||
return newTasks;
|
||
});
|
||
|
||
// Update DB
|
||
try {
|
||
await fetch("/api/tasks", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: idToUpdate,
|
||
recurrenceEndDate: dateStr,
|
||
}),
|
||
});
|
||
fetchTasks();
|
||
} catch (error) {
|
||
console.error("Failed to stop recurring series:", error);
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* Recurring Task Delete Confirmation Modal */}
|
||
{
|
||
recurringDeleteModal.isOpen && (
|
||
<div
|
||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] px-4"
|
||
onClick={() =>
|
||
setRecurringDeleteModal({ isOpen: false, taskId: null })
|
||
}
|
||
>
|
||
<div
|
||
className="bg-white dark:bg-zinc-900 p-8 rounded-2xl shadow-2xl w-full max-w-md border border-zinc-100 dark:border-zinc-800"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
|
||
<Repeat size={24} />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-xl font-black uppercase tracking-tight">
|
||
Recurring Task
|
||
</h3>
|
||
<p className="text-xs text-zinc-500 dark:text-zinc-400 font-bold uppercase tracking-widest">
|
||
Deletion Options
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4 mb-8">
|
||
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl border border-zinc-100 dark:border-zinc-800">
|
||
<p className="text-sm font-medium text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
||
How would you like to delete this task?
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
<button
|
||
onClick={() =>
|
||
recurringDeleteModal.taskId &&
|
||
handleConfirmDeleteSeries(recurringDeleteModal.taskId)
|
||
}
|
||
className="group flex items-center gap-4 p-4 bg-red-600 hover:bg-red-700 text-white rounded-xl transition-all shadow-lg hover:shadow-red-500/20 text-left"
|
||
>
|
||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
|
||
<Repeat size={20} />
|
||
</div>
|
||
<div>
|
||
<span className="block font-black uppercase text-xs tracking-widest">
|
||
Entire Series
|
||
</span>
|
||
<span className="text-[10px] opacity-80 font-bold">
|
||
Stop recurrence & remove all future
|
||
</span>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() =>
|
||
recurringDeleteModal.taskId &&
|
||
handleConfirmDeleteOccurrence(recurringDeleteModal.taskId)
|
||
}
|
||
className="group flex items-center gap-4 p-4 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-900 dark:text-zinc-100 rounded-xl transition-all text-left"
|
||
>
|
||
<div className="w-10 h-10 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
|
||
<Calendar size={20} />
|
||
</div>
|
||
<div>
|
||
<span className="block font-black uppercase text-xs tracking-widest">
|
||
Only This One
|
||
</span>
|
||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400 font-bold">
|
||
Only remove the selected instance
|
||
</span>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() =>
|
||
setRecurringDeleteModal({ isOpen: false, taskId: null })
|
||
}
|
||
className="w-full mt-2 p-3 text-xs font-black text-zinc-400 dark:text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 uppercase tracking-widest transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
{/* 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, weekStartDay),
|
||
))),
|
||
);
|
||
|
||
// 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 Sidebar */}
|
||
{
|
||
showSettings && (
|
||
<SettingsSidebar
|
||
initialTab={activeTab}
|
||
onRemoveConnection={handleRemoveConnection}
|
||
onClose={() => setShowSettings(false)}
|
||
onSettingsChanged={handleSettingsChanged}
|
||
showTimeGrid={showTimeGrid}
|
||
setShowTimeGrid={setShowTimeGrid}
|
||
cellDuration={cellDuration}
|
||
setCellDuration={setCellDuration}
|
||
weekStartDay={weekStartDay}
|
||
setWeekStartDay={setWeekStartDay}
|
||
viewStyle={viewStyle}
|
||
setViewStyle={setViewStyle}
|
||
showSomeday={showSomeday}
|
||
setShowSomeday={setShowSomeday}
|
||
showAllDay={showAllDay}
|
||
setShowAllDay={setShowAllDay}
|
||
showSchedule={showSchedule}
|
||
setShowSchedule={setShowSchedule}
|
||
goal={goal}
|
||
setGoal={setGoal}
|
||
saveGoal={saveGoal}
|
||
connections={connections}
|
||
onUpdateConnections={setConnections}
|
||
focusTimerDuration={focusTimerDuration}
|
||
setFocusTimerDuration={setFocusTimerDuration}
|
||
focusBreakDuration={focusBreakDuration}
|
||
setFocusBreakDuration={setFocusBreakDuration}
|
||
fontSize={fontSize}
|
||
setFontSize={setFontSize}
|
||
showNextTask={showNextTask}
|
||
setShowNextTask={setShowNextTask}
|
||
headlineFont={headlineFont}
|
||
headlineFontSize={headlineFontSize}
|
||
headlineFontWeight={headlineFontWeight}
|
||
dateFontFamily={dateFontFamily}
|
||
dateFontSize={dateFontSize}
|
||
dateFontWeight={dateFontWeight}
|
||
timeTaskFontFamily={timeTaskFontFamily}
|
||
timeTaskFontSize={timeTaskFontSize}
|
||
timeTaskFontWeight={timeTaskFontWeight}
|
||
bodyFont={bodyFont}
|
||
taskFontFamily={taskFontFamily}
|
||
taskFontSize={taskFontSize}
|
||
taskFontWeight={taskFontWeight}
|
||
fontWeight={fontWeight}
|
||
weekendColorSat={weekendColorSat}
|
||
weekendColorSun={weekendColorSun}
|
||
protectEventTimes={protectEventTimes}
|
||
setProtectEventTimes={setProtectEventTimes}
|
||
goalFontWeight={profile.goalFontWeight || "500"}
|
||
goalFallbackType={profile.goalFallbackType}
|
||
goalDefaultSentence={profile.goalDefaultSentence}
|
||
importingTasksState={importingTasksState}
|
||
executeImport={executeImport}
|
||
onImportLists={(lists) => doImport("apple", lists)}
|
||
importStatusMsg={importStatusMsg}
|
||
hourLabelFormat={hourLabelFormat}
|
||
setHourLabelFormat={setHourLabelFormat}
|
||
showSubHourSlots={showSubHourSlots}
|
||
setShowSubHourSlots={setShowSubHourSlots}
|
||
allDayPosition={allDayPosition}
|
||
setAllDayPosition={setAllDayPosition}
|
||
saveSetting={saveSetting}
|
||
availableTaskLists={availableTaskLists}
|
||
isFetchingProviderLists={isFetchingProviderLists}
|
||
somedayLists={somedayLists}
|
||
handleToggleTaskList={handleToggleTaskList}
|
||
unsyncConfirm={unsyncConfirm}
|
||
onConfirmUnsync={confirmUnsync}
|
||
onCancelUnsync={() => setUnsyncConfirm(null)}
|
||
handleSyncAll={handleSyncAll}
|
||
fetchAvailableTaskLists={fetchAvailableTaskLists}
|
||
setCurrentWeekStart={setCurrentWeekStart}
|
||
projects={projects}
|
||
onProjectsChanged={fetchProjects}
|
||
kanbanStages={kanbanStages}
|
||
saveKanbanStages={saveKanbanStages}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{
|
||
selectedTaskForRecurrence && (
|
||
<TaskRecurrenceModal
|
||
task={selectedTaskForRecurrence}
|
||
onClose={() => setSelectedTaskForRecurrence(null)}
|
||
onSave={handleRecurrenceSave}
|
||
language={language}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{
|
||
selectedTaskForNotes && (
|
||
<NotesSidebar
|
||
task={selectedTaskForNotes}
|
||
onClose={() => setSelectedTaskForNotes(null)}
|
||
updateTaskNotes={updateTaskNotes}
|
||
/>
|
||
)
|
||
}
|
||
<ImportListModal
|
||
isOpen={isImportModalOpen}
|
||
onClose={() => setIsImportModalOpen(false)}
|
||
onImport={handleConfirmImport}
|
||
provider={importProvider}
|
||
lists={importLists}
|
||
isLoading={isFetchingLists}
|
||
/>
|
||
|
||
{/* Mobile: Floating Action Button for quick task creation */}
|
||
{isMobile && !showMobileFabSheet && !showSettings && !showFocusMode && (
|
||
<button
|
||
className="mobile-fab"
|
||
onClick={() => setShowMobileFabSheet(true)}
|
||
title="Add task"
|
||
>
|
||
<Plus size={28} />
|
||
</button>
|
||
)}
|
||
|
||
{/* Mobile: Bottom Sheet for task creation */}
|
||
{isMobile && showMobileFabSheet && (
|
||
<>
|
||
<div className="bottom-sheet-backdrop" onClick={() => { setShowMobileFabSheet(false); setFabTaskTitle(""); }} />
|
||
<div className="bottom-sheet">
|
||
<div className="bottom-sheet-handle" />
|
||
<textarea
|
||
ref={fabTextareaRef}
|
||
value={fabTaskTitle}
|
||
onChange={(e) => setFabTaskTitle(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
if (fabTaskTitle.trim()) {
|
||
// Find today's date and add task
|
||
const today = new Date();
|
||
const todayStr = formatDateToISO(today);
|
||
addTask(today, fabTaskTitle.trim());
|
||
setFabTaskTitle("");
|
||
setShowMobileFabSheet(false);
|
||
}
|
||
}
|
||
}}
|
||
placeholder="What do you need to do?"
|
||
rows={2}
|
||
style={{
|
||
width: "100%",
|
||
border: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`,
|
||
borderRadius: "12px",
|
||
padding: "12px 16px",
|
||
fontSize: "1rem",
|
||
background: darkMode ? "#111827" : "#f9fafb",
|
||
color: darkMode ? "#e5e7eb" : "#333",
|
||
outline: "none",
|
||
resize: "none",
|
||
fontFamily: "inherit",
|
||
}}
|
||
/>
|
||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "12px", gap: "8px" }}>
|
||
<button
|
||
onClick={() => { setShowMobileFabSheet(false); setFabTaskTitle(""); }}
|
||
style={{ padding: "8px 16px", borderRadius: "8px", border: "none", background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#e5e7eb" : "#333", fontSize: "0.85rem", cursor: "pointer" }}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
if (fabTaskTitle.trim()) {
|
||
const today = new Date();
|
||
addTask(today, fabTaskTitle.trim());
|
||
setFabTaskTitle("");
|
||
setShowMobileFabSheet(false);
|
||
}
|
||
}}
|
||
style={{ padding: "8px 16px", borderRadius: "8px", border: "none", background: "#0ea5e9", color: "white", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}
|
||
>
|
||
Add Task
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Mobile: Date Picker as centered modal overlay */}
|
||
{isMobile && showDatePicker && (
|
||
<div className="mobile-date-picker-overlay" onClick={() => setShowDatePicker(false)}>
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<SimpleDatePicker
|
||
selected={currentWeekStart}
|
||
onSelect={(date) => {
|
||
setCurrentWeekStart(getStartOfWeek(date));
|
||
setShowDatePicker(false);
|
||
}}
|
||
onClose={() => setShowDatePicker(false)}
|
||
language={language}
|
||
/>
|
||
</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 = () => {
|
||
if (newTaskTitle.trim()) {
|
||
onAddTask(newTaskTitle);
|
||
setNewTaskTitle("");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="weekly-task-input"
|
||
onDragOver={onDragOver}
|
||
onDrop={onDrop}
|
||
>
|
||
<textarea
|
||
value={newTaskTitle}
|
||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleAddTask();
|
||
}
|
||
}}
|
||
placeholder="Type a to-do..."
|
||
rows={newTaskTitle.split("\n").length || 1}
|
||
style={{
|
||
resize: "none",
|
||
overflow: "hidden",
|
||
fontFamily: "inherit",
|
||
lineHeight: "inherit",
|
||
width: "100%",
|
||
border: "none",
|
||
background: "transparent",
|
||
outline: "none",
|
||
padding: "inherit",
|
||
fontSize: "inherit",
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SomedayAddTask({
|
||
listId,
|
||
onAdd,
|
||
onCancel,
|
||
slotIdx,
|
||
}: {
|
||
listId: string;
|
||
onAdd: (title: string) => void;
|
||
onCancel?: () => void;
|
||
slotIdx?: number;
|
||
}) {
|
||
const [title, setTitle] = useState("");
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleSubmit = () => {
|
||
if (title.trim()) {
|
||
onAdd(title.trim());
|
||
setTitle("");
|
||
} else if (onCancel) {
|
||
onCancel();
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (slotIdx !== undefined && inputRef.current) {
|
||
inputRef.current.focus();
|
||
}
|
||
}, [slotIdx]);
|
||
|
||
return (
|
||
<li
|
||
className="weekly-task-item minimal"
|
||
style={{
|
||
margin: slotIdx !== undefined ? "0" : "0 0.5rem",
|
||
listStyle: "none",
|
||
width: "100%"
|
||
}}
|
||
>
|
||
<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("");
|
||
if (onCancel) onCancel();
|
||
else e.currentTarget.blur();
|
||
}
|
||
}}
|
||
className="weekly-task-text"
|
||
style={{
|
||
width: "100%",
|
||
border: "none",
|
||
background: "transparent",
|
||
padding: "0 0",
|
||
fontSize: "0.9375rem",
|
||
outline: "none",
|
||
height: slotIdx !== undefined ? "auto" : "24px",
|
||
display: "block",
|
||
}}
|
||
placeholder={slotIdx !== undefined ? "" : "Add task..."}
|
||
data-someday-add-input={slotIdx !== undefined ? undefined : 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;
|
||
onAddSubTask?: (parentId: string, title: string) => void;
|
||
onToggleSubTask?: (subTaskId: string) => void;
|
||
onDeleteSubTask?: (subTaskId: string) => void;
|
||
onUpdateSubTask?: (subTaskId: string, title: string) => void;
|
||
editingTaskId?: string | null;
|
||
onSetEditingTaskId?: (id: string | null) => void;
|
||
isSubTask?: boolean;
|
||
showTaskCheckboxes?: boolean;
|
||
projects?: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
||
kanbanStages?: KanbanStage[];
|
||
}
|
||
|
||
function TaskItem({
|
||
task,
|
||
isEditing,
|
||
onToggle,
|
||
onEdit,
|
||
onUpdate,
|
||
onDelete,
|
||
onNotes,
|
||
onRollToggle,
|
||
onRecurrence,
|
||
onDragStart,
|
||
onDragEnd,
|
||
variant = "default",
|
||
isSomeday = false,
|
||
onAddSubTask,
|
||
onToggleSubTask,
|
||
onDeleteSubTask,
|
||
onUpdateSubTask,
|
||
editingTaskId,
|
||
onSetEditingTaskId,
|
||
isSubTask = false,
|
||
showTaskCheckboxes = false,
|
||
projects = [],
|
||
onProjectAssign,
|
||
kanbanStages = [],
|
||
}: TaskItemProps) {
|
||
const [editValue, setEditValue] = useState(task.title);
|
||
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
||
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
|
||
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
|
||
const [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
|
||
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
|
||
const [showProjectPicker, setShowProjectPicker] = useState(false);
|
||
const projectPickerRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||
const subTaskInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Touch: tap-to-reveal actions
|
||
const [touchActive, setTouchActive] = useState(false);
|
||
const taskItemRef = useRef<HTMLLIElement>(null);
|
||
|
||
// Touch: swipe gesture state
|
||
const [swipeX, setSwipeX] = useState(0);
|
||
const swipeTouchStart = useRef({ x: 0, y: 0, swiping: false });
|
||
|
||
// Close touch-active on outside click
|
||
useEffect(() => {
|
||
if (!touchActive) return;
|
||
const handler = (e: Event) => {
|
||
if (taskItemRef.current && !taskItemRef.current.contains(e.target as Node)) {
|
||
setTouchActive(false);
|
||
}
|
||
};
|
||
document.addEventListener("touchstart", handler);
|
||
document.addEventListener("mousedown", handler);
|
||
return () => {
|
||
document.removeEventListener("touchstart", handler);
|
||
document.removeEventListener("mousedown", handler);
|
||
};
|
||
}, [touchActive]);
|
||
|
||
// Close project picker on outside click
|
||
useEffect(() => {
|
||
if (!showProjectPicker) return;
|
||
const handler = (e: Event) => {
|
||
if (projectPickerRef.current && !projectPickerRef.current.contains(e.target as Node)) {
|
||
setShowProjectPicker(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handler);
|
||
return () => document.removeEventListener("mousedown", handler);
|
||
}, [showProjectPicker]);
|
||
|
||
const needsSync = task.externalProvider && (
|
||
!task.externalId ||
|
||
!task.lastSyncedAt ||
|
||
new Date(task.updatedAt) > new Date(task.lastSyncedAt)
|
||
);
|
||
|
||
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 === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
(e.target as HTMLElement).blur();
|
||
}
|
||
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
|
||
ref={taskItemRef}
|
||
className={`weekly-task-item ${variant} ${task.completed ? "completed" : ""} ${task.completed && showTaskCheckboxes ? "completed-with-checkbox" : ""} ${isSomeday ? "relative mx-2 w-full" : ""} ${touchActive ? "touch-active" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`}
|
||
style={(() => {
|
||
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
|
||
if (stageColor) return { borderLeft: `4px solid ${stageColor}`, paddingLeft: "6px" };
|
||
if (task.project?.color) return { borderLeft: `3px solid ${task.project.color}`, paddingLeft: "6px" };
|
||
return undefined;
|
||
})()}
|
||
draggable={!isEditing && !isNotesOpen && swipeX === 0}
|
||
onDragStart={(e) => {
|
||
// If dragging a subtask, don't drag the parent
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest('.subtask-list')) {
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
onDragStart(e as unknown as DragEvent, task);
|
||
}}
|
||
onDragEnd={onDragEnd}
|
||
onClick={(e) => {
|
||
// Touch: toggle action toolbar on tap
|
||
if (window.matchMedia("(pointer: coarse)").matches && !isEditing) {
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest(".task-actions") || target.closest("button")) return;
|
||
setTouchActive(!touchActive);
|
||
return;
|
||
}
|
||
if ((variant === "minimal" || isSomeday) && !isEditing) {
|
||
const target = e.target as HTMLElement;
|
||
if (
|
||
target.tagName === "BUTTON" ||
|
||
target.tagName === "INPUT" ||
|
||
target.closest("button")
|
||
)
|
||
return;
|
||
onEdit();
|
||
}
|
||
}}
|
||
onTouchStart={(e) => {
|
||
const touch = e.touches[0];
|
||
swipeTouchStart.current = { x: touch.clientX, y: touch.clientY, swiping: false };
|
||
setSwipeX(0);
|
||
}}
|
||
onTouchMove={(e) => {
|
||
const touch = e.touches[0];
|
||
const dx = touch.clientX - swipeTouchStart.current.x;
|
||
const dy = touch.clientY - swipeTouchStart.current.y;
|
||
// Only swipe if horizontal dominant and past 10px threshold
|
||
if (!swipeTouchStart.current.swiping && Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||
swipeTouchStart.current.swiping = true;
|
||
}
|
||
if (swipeTouchStart.current.swiping) {
|
||
e.preventDefault();
|
||
setSwipeX(dx);
|
||
}
|
||
}}
|
||
onTouchEnd={() => {
|
||
if (Math.abs(swipeX) > 80) {
|
||
if (swipeX > 0) {
|
||
// Swipe right: toggle complete
|
||
onToggle();
|
||
} else {
|
||
// Swipe left: delete
|
||
onDelete();
|
||
}
|
||
}
|
||
setSwipeX(0);
|
||
swipeTouchStart.current.swiping = false;
|
||
}}
|
||
>
|
||
{/* Swipe indicators */}
|
||
{swipeX > 20 && (
|
||
<div className="task-swipe-indicator complete" style={{ width: Math.abs(swipeX) }}>
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
|
||
</div>
|
||
)}
|
||
{swipeX < -20 && (
|
||
<div className="task-swipe-indicator delete" style={{ width: Math.abs(swipeX) }}>
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||
</div>
|
||
)}
|
||
<div style={{ width: "100%", position: "relative", transform: swipeX !== 0 ? `translateX(${swipeX}px)` : undefined, transition: swipeX === 0 ? "transform 0.2s ease" : "none", background: "inherit" }}>
|
||
{/* Visual Indicator for Rolling Tasks */}
|
||
{task.isRolling && !task.completed && !isSomeday && (
|
||
<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" ? (
|
||
<textarea
|
||
ref={inputRef}
|
||
className="weekly-task-text"
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={() => onUpdate(editValue)}
|
||
rows={editValue.split("\n").length || 1}
|
||
style={{
|
||
border: "none",
|
||
background: "transparent",
|
||
outline: "none",
|
||
width: "100%",
|
||
padding: "0",
|
||
resize: "none",
|
||
overflow: "hidden",
|
||
fontFamily: "inherit",
|
||
fontSize: "inherit",
|
||
fontWeight: "inherit",
|
||
lineHeight: "inherit",
|
||
}}
|
||
/>
|
||
) : (
|
||
<textarea
|
||
ref={inputRef}
|
||
className="weekly-task-text"
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={() => onUpdate(editValue)}
|
||
rows={editValue.split("\n").length || 1}
|
||
style={{
|
||
resize: "none",
|
||
overflow: "hidden",
|
||
fontFamily: "inherit",
|
||
lineHeight: "inherit",
|
||
}}
|
||
/>
|
||
)}
|
||
</form>
|
||
) : (
|
||
<>
|
||
<span
|
||
className={`weekly-task-text flex-1 ${task.completed ? "completed" : ""}`}
|
||
onClick={(e) => {
|
||
if (variant === "default" && !showTaskCheckboxes) onToggle();
|
||
// For minimal/someday, parent onClick handles edit
|
||
}}
|
||
onDoubleClick={variant === "default" ? onEdit : undefined}
|
||
style={
|
||
variant === "minimal" || isSomeday
|
||
? { display: "flex", alignItems: "center", gap: "4px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
|
||
: { display: "flex", alignItems: "center", gap: "6px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
|
||
}
|
||
>
|
||
{showTaskCheckboxes && (
|
||
<input
|
||
type="checkbox"
|
||
checked={task.completed}
|
||
onChange={(e) => { e.stopPropagation(); onToggle(); }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="task-checkbox flex-shrink-0"
|
||
style={{
|
||
width: "16px",
|
||
height: "16px",
|
||
margin: 0,
|
||
cursor: "pointer",
|
||
position: "relative",
|
||
top: "3px",
|
||
left: "-2px",
|
||
accentColor: "var(--weekly-teal, #009a9a)",
|
||
WebkitAppearance: "checkbox"
|
||
}}
|
||
/>
|
||
)}
|
||
<span
|
||
style={{
|
||
whiteSpace: "pre-wrap",
|
||
wordBreak: "break-word",
|
||
overflow: "visible",
|
||
flex: 1,
|
||
}}
|
||
>
|
||
{task.title}
|
||
</span>
|
||
{(() => {
|
||
const provider = task.externalProvider
|
||
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
|
||
if (!provider) return null;
|
||
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
|
||
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
|
||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
||
};
|
||
const info = iconMap[provider];
|
||
if (!info) return null;
|
||
return (
|
||
<span
|
||
className="flex-shrink-0"
|
||
title={`Synced with ${info.label}`}
|
||
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "auto", paddingLeft: "4px" }}
|
||
>
|
||
<FontAwesomeIcon icon={info.icon} style={{ width: 12, height: 12, color: info.color }} />
|
||
</span>
|
||
);
|
||
})()}
|
||
</span>
|
||
|
||
{/* Subtask indicator - toggles subtask list */}
|
||
{task.subTasks && task.subTasks.length > 0 && !isSubTask && (() => {
|
||
const completed = task.subTasks!.filter(s => s.completed).length;
|
||
const total = task.subTasks!.length;
|
||
const allDone = completed === total;
|
||
const expanded = isSubTasksOpen || isSubTaskInputOpen;
|
||
return (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setIsSubTasksOpen(!isSubTasksOpen);
|
||
}}
|
||
className="subtask-indicator-badge"
|
||
title={expanded ? "Collapse subtasks" : `${completed}/${total} subtasks done`}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: "3px",
|
||
padding: "2px 7px",
|
||
borderRadius: "10px",
|
||
background: expanded ? "rgba(99, 102, 241, 0.15)" : allDone ? "rgba(34, 197, 94, 0.15)" : "rgba(0,0,0,0.08)",
|
||
color: expanded ? "#6366f1" : allDone ? "#22c55e" : "#555",
|
||
border: expanded ? "1px solid rgba(99, 102, 241, 0.3)" : allDone ? "1px solid rgba(34, 197, 94, 0.3)" : "1px solid rgba(0,0,0,0.12)",
|
||
cursor: "pointer",
|
||
fontSize: "0.7rem",
|
||
fontWeight: 600,
|
||
lineHeight: 1,
|
||
flexShrink: 0,
|
||
whiteSpace: "nowrap",
|
||
marginTop: "2px",
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
|
||
<polyline points="9 18 15 12 9 6" />
|
||
</svg>
|
||
{completed}/{total}
|
||
</button>
|
||
);
|
||
})()}
|
||
|
||
{/* Note indicator - toggles inline notes */}
|
||
{task.markdownContent && task.markdownContent.trim().length > 0 && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setIsNotesOpen(!isNotesOpen);
|
||
}}
|
||
className="focus:outline-none flex-shrink-0"
|
||
title={isNotesOpen ? "Collapse note" : "Expand note"}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
padding: "2px 4px",
|
||
borderRadius: "3px",
|
||
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.05)",
|
||
color: isNotesOpen ? "#f59e0b" : "#888",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
lineHeight: 1,
|
||
marginTop: "2px",
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||
<polyline points="14 2 14 8 20 8" />
|
||
<line x1="16" y1="13" x2="8" y2="13" />
|
||
<line x1="16" y1="17" x2="8" y2="17" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
<div className="task-actions z-20">
|
||
{/* Complete */}
|
||
<button
|
||
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onToggle();
|
||
}}
|
||
title={task.completed ? "Mark incomplete" : "Mark complete"}
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
width="12"
|
||
height="12"
|
||
stroke="currentColor"
|
||
strokeWidth="3"
|
||
fill="none"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
</button>
|
||
{/* Edit */}
|
||
<button
|
||
className="task-action-btn"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onEdit();
|
||
}}
|
||
title="Edit"
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
width="12"
|
||
height="12"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
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>
|
||
|
||
{/* Add Sub-task */}
|
||
{!isSubTask && onAddSubTask && (
|
||
<button
|
||
className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setIsSubTaskInputOpen(!isSubTaskInputOpen);
|
||
if (!isSubTaskInputOpen) {
|
||
setTimeout(() => subTaskInputRef.current?.focus(), 50);
|
||
}
|
||
}}
|
||
title="Add sub-task"
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
width="12"
|
||
height="12"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
fill="none"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* Recurrence (hidden for someday tasks) */}
|
||
{!isSomeday && (
|
||
<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="12"
|
||
height="12"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
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="12"
|
||
height="12"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
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>
|
||
|
||
{/* Project Assignment */}
|
||
{!isSubTask && projects.length > 0 && onProjectAssign && (
|
||
<div className="relative" ref={projectPickerRef}>
|
||
<button
|
||
className={`task-action-btn ${task.project ? "active" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setShowProjectPicker(!showProjectPicker);
|
||
}}
|
||
title={task.project ? task.project.name : "Assign project"}
|
||
>
|
||
<Circle
|
||
size={12}
|
||
fill={task.project?.color || "none"}
|
||
stroke={task.project?.color || "currentColor"}
|
||
strokeWidth={2}
|
||
/>
|
||
</button>
|
||
{showProjectPicker && (
|
||
<div className="absolute z-50 top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
|
||
{task.projectId && (
|
||
<button
|
||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onProjectAssign(task.id, null);
|
||
setShowProjectPicker(false);
|
||
}}
|
||
>
|
||
<X size={10} /> Remove
|
||
</button>
|
||
)}
|
||
{projects.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 ${task.projectId === p.id ? "font-bold" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onProjectAssign(task.id, task.projectId === p.id ? null : p.id);
|
||
setShowProjectPicker(false);
|
||
}}
|
||
>
|
||
<Circle size={10} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
|
||
{p.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 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="12"
|
||
height="12"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
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 text-red-500 hover:text-red-700 hover:bg-red-100/50 dark:hover:bg-red-900/30 rounded"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDelete();
|
||
}}
|
||
title="Delete"
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
width="12"
|
||
height="12"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
fill="none"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<polyline points="3 6 5 6 21 6"></polyline>
|
||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||
</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>
|
||
)
|
||
}
|
||
|
||
{/* Sub-tasks section */}
|
||
{
|
||
!isSubTask && (isSubTasksOpen || isSubTaskInputOpen) && task.subTasks && task.subTasks.length > 0 && (
|
||
<ul className="subtask-list" onClick={(e) => e.stopPropagation()} style={task.project?.color ? { borderLeft: `2px solid ${task.project.color}`, marginLeft: "2px" } : undefined}>
|
||
{task.subTasks.map((subTask) => (
|
||
<li
|
||
key={subTask.id}
|
||
className={`subtask-item ${subTask.completed ? "completed" : ""}`}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
e.stopPropagation();
|
||
if (onDragStart) {
|
||
onDragStart(e as any, { ...subTask, parentTaskId: task.id, scheduledDate: task.scheduledDate } as any);
|
||
}
|
||
}}
|
||
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
|
||
>
|
||
<button
|
||
className="subtask-checkbox"
|
||
onClick={() => onToggleSubTask?.(subTask.id)}
|
||
aria-label={subTask.completed ? "Mark incomplete" : "Mark complete"}
|
||
>
|
||
{subTask.completed ? (
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="20 6 9 17 4 12" />
|
||
</svg>
|
||
) : (
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
{editingTaskId === subTask.id ? (
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const input = e.currentTarget.querySelector("input");
|
||
if (input) {
|
||
onUpdateSubTask?.(subTask.id, input.value);
|
||
onSetEditingTaskId?.(null);
|
||
}
|
||
}}
|
||
style={{ flex: 1 }}
|
||
>
|
||
<input
|
||
type="text"
|
||
defaultValue={subTask.title}
|
||
autoFocus
|
||
className="subtask-edit-input"
|
||
onBlur={(e) => {
|
||
onUpdateSubTask?.(subTask.id, e.target.value);
|
||
onSetEditingTaskId?.(null);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") onSetEditingTaskId?.(null);
|
||
}}
|
||
/>
|
||
</form>
|
||
) : (
|
||
<span
|
||
className={`subtask-title ${subTask.completed ? "completed" : ""}`}
|
||
onClick={() => onSetEditingTaskId?.(subTask.id)}
|
||
>
|
||
{subTask.title}
|
||
</span>
|
||
)}
|
||
<button
|
||
className="subtask-delete-btn"
|
||
onClick={() => onDeleteSubTask?.(subTask.id)}
|
||
title="Remove sub-task"
|
||
>
|
||
<svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18" />
|
||
<line x1="6" y1="6" x2="18" y2="18" />
|
||
</svg>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|
||
|
||
{/* Add sub-task input */}
|
||
{
|
||
!isSubTask && isSubTaskInputOpen && (
|
||
<div className="subtask-add-row" onClick={(e) => e.stopPropagation()}>
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (newSubTaskTitle.trim()) {
|
||
onAddSubTask?.(task.id, newSubTaskTitle.trim());
|
||
setNewSubTaskTitle("");
|
||
}
|
||
}}
|
||
style={{ display: "flex", alignItems: "center", gap: "0.25rem", flex: 1 }}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="var(--weekly-text-muted, #999)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||
<circle cx="12" cy="12" r="10" />
|
||
</svg>
|
||
<input
|
||
ref={subTaskInputRef}
|
||
type="text"
|
||
value={newSubTaskTitle}
|
||
onChange={(e) => setNewSubTaskTitle(e.target.value)}
|
||
onBlur={() => {
|
||
if (!newSubTaskTitle.trim()) {
|
||
setIsSubTaskInputOpen(false);
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
setNewSubTaskTitle("");
|
||
setIsSubTaskInputOpen(false);
|
||
}
|
||
}}
|
||
placeholder="Add sub-task..."
|
||
className="subtask-add-input"
|
||
autoFocus
|
||
/>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
</div >
|
||
</li >
|
||
);
|
||
}
|
||
|
||
// Settings Modal Component
|
||
interface SettingsSidebarProps {
|
||
onClose: () => void;
|
||
onSettingsChanged?: (newSettings: {
|
||
showTimeGrid: boolean;
|
||
cellDuration: CellDuration;
|
||
viewStyle: ViewStyle;
|
||
language: string;
|
||
dateFormat: string;
|
||
timeFormat: string;
|
||
startHour: number;
|
||
endHour: number;
|
||
fontSize: "S" | "M" | "L";
|
||
showNextTask: boolean;
|
||
showSomeday: boolean;
|
||
showAllDayEvents: boolean;
|
||
showSchedule: boolean;
|
||
headlineFont: string;
|
||
headlineFontSize: string;
|
||
headlineFontWeight: string;
|
||
goalFontWeight: string;
|
||
dateFontFamily: string;
|
||
dateFontSize: string;
|
||
dateFontWeight: string;
|
||
timeTaskFontFamily: string;
|
||
timeTaskFontSize: string;
|
||
timeTaskFontWeight: string;
|
||
bodyFont: string;
|
||
taskFontFamily: string;
|
||
taskFontSize: string;
|
||
taskFontWeight: string;
|
||
fontWeight: string;
|
||
weekendColorSat: string;
|
||
weekendColorSun: string;
|
||
weekdayColor?: string;
|
||
dateColor?: string;
|
||
taskColor?: string;
|
||
todayHighlightColor?: string;
|
||
dateLayout?: "above" | "below" | "left" | "right" | "hidden";
|
||
mobileDateLayout?: "above" | "below" | "left" | "right" | "hidden";
|
||
dateAlignment?: "left" | "center" | "right" | "tight";
|
||
hourLabelFormat?: "short" | "full";
|
||
showSubHourSlots?: boolean;
|
||
allDayPosition?: "above" | "below";
|
||
cwFontFamily?: string;
|
||
cwFontSize?: string;
|
||
cwFontWeight?: string;
|
||
cwColor?: string;
|
||
yearFontFamily?: string;
|
||
yearFontSize?: string;
|
||
yearFontWeight?: string;
|
||
yearColor?: string;
|
||
dayHeaderGap?: string;
|
||
showTaskCheckboxes?: boolean;
|
||
startDayOffset?: number;
|
||
quoteSourceUrls: string[];
|
||
quoteLanguages: string[];
|
||
}) => void;
|
||
setCurrentWeekStart: (d: Date) => void;
|
||
quoteSourceUrls?: string[];
|
||
quoteLanguages?: string[];
|
||
goal: string;
|
||
setGoal: (goal: string) => void;
|
||
saveGoal: (goal: string) => void;
|
||
connections: any[];
|
||
onUpdateConnections: (connections: any[]) => void;
|
||
onRemoveConnection: (id: string) => void | Promise<void>;
|
||
focusTimerDuration: number;
|
||
setFocusTimerDuration: (duration: number) => void;
|
||
focusBreakDuration: number;
|
||
setFocusBreakDuration: (duration: number) => void;
|
||
showNextTask: boolean;
|
||
setShowNextTask: (show: boolean) => void;
|
||
protectEventTimes: boolean;
|
||
setProtectEventTimes: (protect: boolean) => void;
|
||
goalDefaultSentence?: string;
|
||
goalFallbackType?: string;
|
||
importingTasksState: boolean;
|
||
executeImport: (provider: "google" | "apple" | "outlook") => Promise<void>;
|
||
onImportLists: (lists: { id: string; title: string }[]) => Promise<void>;
|
||
importStatusMsg: { type: "success" | "error"; text: string } | null;
|
||
showTimeGrid: boolean;
|
||
setShowTimeGrid: (show: boolean) => void;
|
||
cellDuration: CellDuration;
|
||
setCellDuration: (duration: CellDuration) => void;
|
||
weekStartDay: number;
|
||
setWeekStartDay: (day: number) => void;
|
||
fontSize: "S" | "M" | "L";
|
||
setFontSize: (size: "S" | "M" | "L") => void;
|
||
headlineFont: string;
|
||
headlineFontSize: string;
|
||
headlineFontWeight: string;
|
||
goalFontWeight: string;
|
||
dateFontFamily: string;
|
||
dateFontSize: string;
|
||
dateFontWeight: string;
|
||
timeTaskFontFamily: string;
|
||
timeTaskFontSize: string;
|
||
timeTaskFontWeight: string;
|
||
bodyFont: string;
|
||
taskFontFamily: string;
|
||
taskFontSize: string;
|
||
taskFontWeight: string;
|
||
fontWeight: string;
|
||
weekendColorSat: string;
|
||
weekendColorSun: string;
|
||
viewStyle: ViewStyle;
|
||
setViewStyle: (style: ViewStyle) => void;
|
||
showSomeday: boolean;
|
||
setShowSomeday: (show: boolean) => void;
|
||
showAllDay: boolean;
|
||
setShowAllDay: (show: boolean) => void;
|
||
showSchedule: boolean;
|
||
setShowSchedule: (show: boolean) => void;
|
||
dateLayout?: "above" | "below" | "left" | "right" | "hidden";
|
||
mobileDateLayout?: "above" | "below" | "left" | "right" | "hidden";
|
||
weekdayFormat?: "long" | "short" | "narrow" | "custom";
|
||
weekdayCase?: "normal" | "capitalize" | "uppercase";
|
||
customWeekdayNames?: string;
|
||
dateAlignment?: "left" | "center" | "right" | "tight";
|
||
hourLabelFormat: "short" | "full";
|
||
setHourLabelFormat: (fmt: "short" | "full") => void;
|
||
showSubHourSlots: boolean;
|
||
setShowSubHourSlots: (show: boolean) => void;
|
||
allDayPosition: "above" | "below";
|
||
setAllDayPosition: (pos: "above" | "below") => void;
|
||
saveSetting: (key: string, value: any) => void;
|
||
availableTaskLists: {
|
||
[key in "google" | "apple" | "outlook" | "synology"]?: { id: string; title: string }[];
|
||
};
|
||
isFetchingProviderLists: Record<string, boolean>;
|
||
somedayLists: SomedayList[];
|
||
handleToggleTaskList: (
|
||
provider: "google" | "apple" | "outlook" | "synology",
|
||
list: { id: string; title: string },
|
||
) => Promise<void>;
|
||
unsyncConfirm: {
|
||
provider: "google" | "apple" | "outlook" | "synology";
|
||
list: { id: string; title: string };
|
||
} | null;
|
||
onConfirmUnsync: () => Promise<void>;
|
||
onCancelUnsync: () => void;
|
||
handleSyncAll: (
|
||
provider: "google" | "outlook" | "synology",
|
||
lists: { id: string; title: string }[],
|
||
syncOn: boolean,
|
||
) => Promise<void>;
|
||
fetchAvailableTaskLists: (
|
||
provider: "google" | "apple" | "outlook" | "synology",
|
||
) => Promise<void>;
|
||
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about";
|
||
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||
onProjectsChanged: () => void;
|
||
kanbanStages: KanbanStage[];
|
||
saveKanbanStages: (stages: KanbanStage[]) => Promise<void>;
|
||
}
|
||
// Notes Sidebar Component
|
||
interface NotesSidebarProps {
|
||
task: Task;
|
||
onClose: () => void;
|
||
updateTaskNotes: (id: string, notes: string) => void;
|
||
}
|
||
|
||
function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const [sidebarWidth, setSidebarWidth] = useState(500);
|
||
const isResizing = useRef(false);
|
||
|
||
useEffect(() => {
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
if (!isResizing.current) return;
|
||
const newWidth = window.innerWidth - e.clientX;
|
||
setSidebarWidth(Math.max(320, Math.min(newWidth, window.innerWidth * 0.9)));
|
||
};
|
||
const handleMouseUp = () => {
|
||
if (isResizing.current) {
|
||
isResizing.current = false;
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
}
|
||
};
|
||
window.addEventListener('mousemove', handleMouseMove);
|
||
window.addEventListener('mouseup', handleMouseUp);
|
||
return () => {
|
||
window.removeEventListener('mousemove', handleMouseMove);
|
||
window.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
const handleClose = () => {
|
||
setIsVisible(false);
|
||
setTimeout(onClose, 300);
|
||
};
|
||
|
||
const handleToolbarClick = (before: string, after: string, selectOffsetStart?: number, selectOffsetEnd?: number) => {
|
||
const textarea = textareaRef.current;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const beforeText = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const afterText = text.substring(end);
|
||
|
||
let newText = `${beforeText}${before}${selection}${after}${afterText}`;
|
||
if (before === "") {
|
||
// Special case for image to match original logic precisely
|
||
newText = `${beforeText}${afterText}`;
|
||
}
|
||
|
||
updateTaskNotes(task.id, newText);
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
|
||
if (before === "") {
|
||
textarea.setSelectionRange(start + 2, start + 10);
|
||
} else {
|
||
textarea.setSelectionRange(
|
||
start + before.length,
|
||
start + before.length + selection.length
|
||
);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
className={`weekly-modal-overlay ${isVisible ? "show" : ""}`}
|
||
onClick={handleClose}
|
||
style={{ zIndex: 1999 }}
|
||
/>
|
||
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`} style={{ width: `${sidebarWidth}px` }}>
|
||
{/* Resize handle */}
|
||
<div
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
isResizing.current = true;
|
||
document.body.style.cursor = 'col-resize';
|
||
document.body.style.userSelect = 'none';
|
||
}}
|
||
style={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: '6px',
|
||
cursor: 'col-resize',
|
||
zIndex: 10,
|
||
}}
|
||
title="Drag to resize"
|
||
/>
|
||
<header className="weekly-notes-sidebar-header">
|
||
<h2 className="weekly-notes-sidebar-title">Notes: {task.title}</h2>
|
||
<button className="weekly-notes-sidebar-close" onClick={handleClose}>
|
||
×
|
||
</button>
|
||
</header>
|
||
<div className="weekly-notes-sidebar-content">
|
||
<div className="notes-toolbar">
|
||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("**", "**")} title="Bold">B</button>
|
||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("*", "*")} title="Italic">i</button>
|
||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("[", "](url)")} title="Link">🔗</button>
|
||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("- ", "")} title="List">☑</button>
|
||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("")} title="Image">🖼️</button>
|
||
</div>
|
||
|
||
<textarea
|
||
ref={textareaRef}
|
||
className="weekly-notes-editor"
|
||
defaultValue={task.markdownContent || ""}
|
||
autoFocus
|
||
placeholder="Add details, notes, or links..."
|
||
onBlur={(e) => updateTaskNotes(task.id, e.target.value)}
|
||
/>
|
||
|
||
<div className="weekly-modal-actions" style={{ marginTop: '24px' }}>
|
||
<button
|
||
className="weekly-btn weekly-btn-secondary"
|
||
onClick={handleClose}
|
||
style={{ width: '100%' }}
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
|
||
function SettingsSidebar({
|
||
onClose,
|
||
onSettingsChanged,
|
||
viewStyle,
|
||
setViewStyle,
|
||
showSomeday,
|
||
setShowSomeday,
|
||
showAllDay,
|
||
setShowAllDay,
|
||
showSchedule,
|
||
setShowSchedule,
|
||
goal,
|
||
setGoal,
|
||
saveGoal,
|
||
connections,
|
||
onUpdateConnections,
|
||
onRemoveConnection,
|
||
focusTimerDuration,
|
||
setFocusTimerDuration,
|
||
focusBreakDuration,
|
||
setFocusBreakDuration,
|
||
showNextTask,
|
||
setShowNextTask,
|
||
protectEventTimes,
|
||
setProtectEventTimes,
|
||
goalFallbackType,
|
||
goalDefaultSentence,
|
||
importingTasksState,
|
||
executeImport,
|
||
onImportLists,
|
||
importStatusMsg,
|
||
showTimeGrid,
|
||
setShowTimeGrid,
|
||
cellDuration,
|
||
setCellDuration,
|
||
weekStartDay,
|
||
setWeekStartDay,
|
||
fontSize,
|
||
setFontSize,
|
||
headlineFont,
|
||
headlineFontSize,
|
||
headlineFontWeight,
|
||
goalFontWeight,
|
||
dateFontFamily,
|
||
dateFontSize,
|
||
dateFontWeight,
|
||
timeTaskFontFamily,
|
||
timeTaskFontSize,
|
||
timeTaskFontWeight,
|
||
bodyFont,
|
||
taskFontFamily,
|
||
taskFontSize,
|
||
taskFontWeight,
|
||
fontWeight,
|
||
weekendColorSat,
|
||
weekendColorSun,
|
||
hourLabelFormat,
|
||
setHourLabelFormat,
|
||
showSubHourSlots,
|
||
setShowSubHourSlots,
|
||
allDayPosition,
|
||
setAllDayPosition,
|
||
saveSetting,
|
||
availableTaskLists,
|
||
isFetchingProviderLists,
|
||
somedayLists,
|
||
handleToggleTaskList,
|
||
unsyncConfirm,
|
||
onConfirmUnsync,
|
||
onCancelUnsync,
|
||
handleSyncAll,
|
||
fetchAvailableTaskLists,
|
||
initialTab,
|
||
setCurrentWeekStart,
|
||
projects,
|
||
onProjectsChanged,
|
||
kanbanStages,
|
||
saveKanbanStages,
|
||
}: SettingsSidebarProps) {
|
||
const [activeTab, setActiveTab] = useState<
|
||
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
|
||
>(initialTab || "general");
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSyncing, setIsSyncing] = useState(false);
|
||
const [exportStartDate, setExportStartDate] = useState("");
|
||
const [exportEndDate, setExportEndDate] = useState("");
|
||
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
|
||
const [importFile, setImportFile] = useState<File | null>(null);
|
||
const [importMsg, setImportMsg] = useState("");
|
||
const [isImporting, setIsImporting] = useState(false);
|
||
const [isExportingAll, setIsExportingAll] = useState(false);
|
||
const [passwords, setPasswords] = useState({ new: "", confirm: "" });
|
||
const [accountMsg, setAccountMsg] = useState("");
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
|
||
// Apple Calendar (CalDAV) State
|
||
const [showAppleCalendarModal, setShowAppleCalendarModal] = useState(false);
|
||
const [appleCalEmail, setAppleCalEmail] = useState("");
|
||
const [appleCalPassword, setAppleCalPassword] = useState("");
|
||
const [isConnectingAppleCal, setIsConnectingAppleCal] = useState(false);
|
||
const [appleCalError, setAppleCalError] = useState("");
|
||
|
||
// Synology Calendar State
|
||
const [showSynologyCalendarModal, setShowSynologyCalendarModal] = useState(false);
|
||
const [synologyCalServerUrl, setSynologyCalServerUrl] = useState("");
|
||
const [synologyCalUsername, setSynologyCalUsername] = useState("");
|
||
const [synologyCalPassword, setSynologyCalPassword] = useState("");
|
||
const [isConnectingSynologyCal, setIsConnectingSynologyCal] = useState(false);
|
||
const [synologyCalError, setSynologyCalError] = useState("");
|
||
|
||
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
|
||
const [confirmDisconnectId, setConfirmDisconnectId] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [newProjectName, setNewProjectName] = useState("");
|
||
const [newProjectColor, setNewProjectColor] = useState("#3b82f6");
|
||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||
const [editProjectName, setEditProjectName] = useState("");
|
||
const [editProjectColor, setEditProjectColor] = useState("");
|
||
|
||
// Fetch lists when the calendar tab is selected
|
||
useEffect(() => {
|
||
if (activeTab === "calendar") {
|
||
const providersWithAccounts = connections.map((c) => c.provider);
|
||
if (providersWithAccounts.includes("google"))
|
||
fetchAvailableTaskLists("google");
|
||
if (providersWithAccounts.includes("outlook"))
|
||
fetchAvailableTaskLists("outlook");
|
||
if (providersWithAccounts.includes("synology"))
|
||
fetchAvailableTaskLists("synology");
|
||
}
|
||
}, [activeTab, connections, fetchAvailableTaskLists]);
|
||
const [connMsg, setConnMsg] = useState<{
|
||
type: "success" | "error";
|
||
text: string;
|
||
} | null>(null);
|
||
|
||
const showConnMsg = (type: "success" | "error", text: string) => {
|
||
setConnMsg({ type, text });
|
||
setTimeout(() => setConnMsg(null), 5000);
|
||
};
|
||
|
||
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;
|
||
focusBreakDuration?: number;
|
||
showTimeGrid?: boolean;
|
||
cellDuration?: number;
|
||
viewStyle?: string;
|
||
fontSize?: "S" | "M" | "L";
|
||
showNextTask?: boolean;
|
||
showSomeday?: boolean;
|
||
showAllDayEvents?: boolean;
|
||
showSchedule?: boolean;
|
||
headlineFont?: string;
|
||
headlineFontSize?: string;
|
||
headlineFontWeight?: string;
|
||
dateFontFamily?: string;
|
||
dateFontSize?: string;
|
||
dateFontWeight?: string;
|
||
timeTaskFontFamily?: string;
|
||
timeTaskFontSize?: string;
|
||
timeTaskFontWeight?: string;
|
||
bodyFont?: string;
|
||
taskFontFamily?: string;
|
||
taskFontSize?: string;
|
||
taskFontWeight?: string;
|
||
eventFontFamily?: string;
|
||
eventFontSize?: string;
|
||
eventFontWeight?: string;
|
||
fontWeight?: string;
|
||
weekendColorSat?: string;
|
||
weekendColorSun?: string;
|
||
weekdayColor?: string;
|
||
dateColor?: string;
|
||
taskColor?: string;
|
||
todayHighlightColor?: string;
|
||
pastDayColor?: string;
|
||
goalFallbackType?: "quote" | "next_todo" | "default";
|
||
quoteSourceUrl?: string;
|
||
goalDefaultSentence?: string;
|
||
goalFontFamily?: string;
|
||
goalFontSize?: string;
|
||
goalFontWeight?: string;
|
||
goalScope?: "week" | "day";
|
||
dateLayout?: "above" | "below" | "left" | "right" | "hidden";
|
||
mobileDateLayout?: "above" | "below" | "left" | "right" | "hidden";
|
||
dateAlignment?: "left" | "center" | "right" | "tight";
|
||
hourLabelFormat?: "short" | "full";
|
||
showSubHourSlots?: boolean;
|
||
allDayPosition?: "above" | "below";
|
||
cwFontFamily?: string;
|
||
cwFontSize?: string;
|
||
cwFontWeight?: string;
|
||
cwColor?: string;
|
||
yearFontFamily?: string;
|
||
yearFontSize?: string;
|
||
yearFontWeight?: string;
|
||
yearColor?: string;
|
||
dayHeaderGap?: string;
|
||
showTaskCheckboxes?: boolean;
|
||
quoteSourceUrls?: string[];
|
||
quoteLanguages?: string[];
|
||
startDayOffset?: number;
|
||
id?: string;
|
||
accountNumber?: number;
|
||
weekdayFormat?: "long" | "short" | "narrow" | "custom";
|
||
weekdayCase?: "normal" | "capitalize" | "uppercase";
|
||
customWeekdayNames?: string;
|
||
dateVerticalAlign?: "top" | "middle" | "bottom";
|
||
}>({
|
||
name: "",
|
||
email: "",
|
||
timezone: "Europe/Berlin",
|
||
autoRolling: false,
|
||
protectEventTimes: false,
|
||
language: "de",
|
||
dateFormat: "yyyy-MM-dd",
|
||
timeFormat: "24h",
|
||
startHour: 8,
|
||
endHour: 18,
|
||
focusTimerDuration: 25,
|
||
focusBreakDuration: 5,
|
||
showTimeGrid: true,
|
||
cellDuration: 30,
|
||
viewStyle: "list",
|
||
fontSize: "M",
|
||
showNextTask: false,
|
||
showSomeday: true,
|
||
showAllDayEvents: true,
|
||
showSchedule: true,
|
||
hourLabelFormat: "short",
|
||
dateVerticalAlign: "middle",
|
||
showSubHourSlots: true,
|
||
allDayPosition: "below",
|
||
goalFallbackType: "quote",
|
||
quoteSourceUrl: "",
|
||
headlineFont: "Inter",
|
||
headlineFontSize: "1.25rem",
|
||
headlineFontWeight: "900",
|
||
dateFontFamily: "Inter",
|
||
dateFontSize: "0.65rem",
|
||
dateFontWeight: "400",
|
||
timeTaskFontFamily: "Inter",
|
||
timeTaskFontSize: "0.75rem",
|
||
timeTaskFontWeight: "500",
|
||
bodyFont: "Inter",
|
||
taskFontFamily: "Inter",
|
||
taskFontSize: "0.9rem",
|
||
taskFontWeight: "400",
|
||
fontWeight: "400",
|
||
goalFontFamily: "Inter",
|
||
goalFontSize: "0.9rem",
|
||
goalFontWeight: "500",
|
||
goalScope: "week",
|
||
dateLayout: "right",
|
||
mobileDateLayout: "below",
|
||
dateAlignment: "center",
|
||
weekendColorSat: "#666666",
|
||
weekendColorSun: "#dc2626",
|
||
weekdayColor: "#888888",
|
||
dateColor: "#888888",
|
||
taskColor: "#333333",
|
||
todayHighlightColor: "#f0fafa",
|
||
pastDayColor: "#a6a6a7",
|
||
weekdayFormat: "long",
|
||
weekdayCase: "capitalize",
|
||
customWeekdayNames: "",
|
||
});
|
||
|
||
const t = translations[profile.language || "en"] || translations["en"];
|
||
|
||
// Load fonts for preview
|
||
// Font loading moved to top level WeeklyView component
|
||
|
||
useEffect(() => {
|
||
try {
|
||
fetchProfile();
|
||
// Trigger slide-in after mount
|
||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||
return () => clearTimeout(timer);
|
||
} catch (e) {
|
||
console.error("Error mounting SettingsSidebar:", e);
|
||
}
|
||
}, []);
|
||
|
||
const handleClose = () => {
|
||
setIsVisible(false);
|
||
setTimeout(onClose, 300);
|
||
};
|
||
|
||
// Live preview: propagate styling changes immediately without Save
|
||
// Auto-save: debounce profile changes to the database
|
||
const profileLoadedRef = useRef(false);
|
||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!profileLoadedRef.current) return; // Skip initial load from API
|
||
// Debounce: save after 800ms of no changes
|
||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||
autoSaveTimerRef.current = setTimeout(async () => {
|
||
try {
|
||
await fetch("/api/user/profile", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(profile),
|
||
});
|
||
console.log("[SETTINGS] Auto-saved profile");
|
||
} catch (err) {
|
||
console.error("[SETTINGS] Auto-save failed:", err);
|
||
}
|
||
}, 800);
|
||
return () => {
|
||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||
};
|
||
}, [profile]);
|
||
useEffect(() => {
|
||
if (!profileLoadedRef.current) return;
|
||
if (!onSettingsChanged) return;
|
||
onSettingsChanged({
|
||
showTimeGrid: showTimeGrid,
|
||
cellDuration: cellDuration,
|
||
viewStyle: viewStyle,
|
||
language: profile.language || "de",
|
||
dateFormat: profile.dateFormat || "MM/dd/yyyy",
|
||
timeFormat: profile.timeFormat || "12h",
|
||
startHour: profile.startHour || 8,
|
||
endHour: profile.endHour || 18,
|
||
fontSize: fontSize,
|
||
showNextTask: showNextTask,
|
||
showSomeday: showSomeday,
|
||
showAllDayEvents: showAllDay,
|
||
showSchedule: showSchedule,
|
||
headlineFont: profile.headlineFont || headlineFont,
|
||
headlineFontSize: profile.headlineFontSize || headlineFontSize,
|
||
headlineFontWeight: profile.headlineFontWeight || headlineFontWeight,
|
||
dateFontFamily: profile.dateFontFamily || dateFontFamily,
|
||
dateFontSize: profile.dateFontSize || dateFontSize,
|
||
dateFontWeight: profile.dateFontWeight || dateFontWeight,
|
||
timeTaskFontFamily: profile.timeTaskFontFamily || timeTaskFontFamily,
|
||
timeTaskFontSize: profile.timeTaskFontSize || timeTaskFontSize,
|
||
timeTaskFontWeight: profile.timeTaskFontWeight || timeTaskFontWeight,
|
||
bodyFont: profile.bodyFont || bodyFont,
|
||
taskFontFamily: profile.taskFontFamily || taskFontFamily,
|
||
taskFontSize: profile.taskFontSize || taskFontSize,
|
||
taskFontWeight: profile.taskFontWeight || taskFontWeight,
|
||
fontWeight: profile.fontWeight || fontWeight,
|
||
weekendColorSat: profile.weekendColorSat || weekendColorSat,
|
||
weekendColorSun: profile.weekendColorSun || weekendColorSun,
|
||
weekdayColor: profile.weekdayColor,
|
||
dateColor: profile.dateColor,
|
||
taskColor: profile.taskColor,
|
||
todayHighlightColor: profile.todayHighlightColor,
|
||
eventFontFamily: profile.eventFontFamily,
|
||
eventFontSize: profile.eventFontSize,
|
||
eventFontWeight: profile.eventFontWeight,
|
||
autoRolling: profile.autoRolling,
|
||
protectEventTimes: profile.protectEventTimes || protectEventTimes,
|
||
pastDayColor: profile.pastDayColor,
|
||
goalScope: profile.goalScope,
|
||
dateLayout: profile.dateLayout,
|
||
mobileDateLayout: profile.mobileDateLayout,
|
||
dateAlignment: profile.dateAlignment,
|
||
goalFontFamily: profile.goalFontFamily,
|
||
goalFontSize: profile.goalFontSize,
|
||
goalFontWeight: profile.goalFontWeight,
|
||
cwColor: profile.cwColor,
|
||
cwFontFamily: profile.cwFontFamily,
|
||
cwFontSize: profile.cwFontSize,
|
||
cwFontWeight: profile.cwFontWeight,
|
||
yearColor: profile.yearColor,
|
||
yearFontFamily: profile.yearFontFamily,
|
||
yearFontSize: profile.yearFontSize,
|
||
yearFontWeight: profile.yearFontWeight,
|
||
dayHeaderGap: profile.dayHeaderGap,
|
||
showTaskCheckboxes: profile.showTaskCheckboxes,
|
||
startDayOffset: profile.startDayOffset,
|
||
weekdayFormat: profile.weekdayFormat,
|
||
weekdayCase: profile.weekdayCase,
|
||
customWeekdayNames: profile.customWeekdayNames,
|
||
} as any);
|
||
}, [profile, showTimeGrid, cellDuration, viewStyle, fontSize, showNextTask, showSomeday, showAllDay, showSchedule]);
|
||
|
||
const handleUpdateConnections = async (updatedConnections: any[]) => {
|
||
onUpdateConnections(updatedConnections);
|
||
};
|
||
|
||
const handleRemoveConnection = async (connectionId: string) => {
|
||
await onRemoveConnection(connectionId);
|
||
};
|
||
|
||
async function fetchProfile() {
|
||
try {
|
||
const res = await fetch("/api/user/profile");
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.user) {
|
||
setProfile({
|
||
id: data.user.id || "",
|
||
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,
|
||
weekdayFormat: data.user.weekdayFormat || "long",
|
||
weekdayCase: data.user.weekdayCase || "capitalize",
|
||
customWeekdayNames: data.user.customWeekdayNames || "",
|
||
viewStyle: data.user.viewStyle || "list",
|
||
fontSize: data.user.fontSize || "M",
|
||
headlineFont: data.user.headlineFont || "Inter",
|
||
bodyFont: data.user.bodyFont || "Inter",
|
||
fontWeight: data.user.fontWeight || "400",
|
||
showSchedule:
|
||
data.user.showSchedule !== undefined
|
||
? data.user.showSchedule
|
||
: true,
|
||
focusBreakDuration: data.user.focusBreakDuration || 5,
|
||
headlineFontSize: data.user.headlineFontSize || "1.25rem",
|
||
headlineFontWeight: data.user.headlineFontWeight || "900",
|
||
goalFontWeight: data.user.goalFontWeight || "500",
|
||
dateFontFamily: data.user.dateFontFamily || "Inter",
|
||
dateFontSize: data.user.dateFontSize || "0.65rem",
|
||
dateFontWeight: data.user.dateFontWeight || "400",
|
||
timeTaskFontFamily: data.user.timeTaskFontFamily || "Inter",
|
||
timeTaskFontSize: data.user.timeTaskFontSize || "0.75rem",
|
||
timeTaskFontWeight: data.user.timeTaskFontWeight || "500",
|
||
taskFontFamily: data.user.taskFontFamily || "Inter",
|
||
taskFontSize: data.user.taskFontSize || "0.9rem",
|
||
taskFontWeight: data.user.taskFontWeight || "400",
|
||
eventFontFamily: data.user.eventFontFamily || "Inter",
|
||
eventFontSize: data.user.eventFontSize || "0.85rem",
|
||
eventFontWeight: data.user.eventFontWeight || "400",
|
||
goalFontFamily: data.user.goalFontFamily || "Inter",
|
||
goalFontSize: data.user.goalFontSize || "0.9rem",
|
||
goalScope: data.user.goalScope || "week",
|
||
dateLayout: data.user.dateLayout || "right",
|
||
mobileDateLayout: data.user.mobileDateLayout || "below",
|
||
dateAlignment: data.user.dateAlignment || "center",
|
||
weekendColorSat: data.user.weekendColorSat || "#666666",
|
||
weekendColorSun: data.user.weekendColorSun || "#dc2626",
|
||
weekdayColor: data.user.weekdayColor || "#888888",
|
||
dateColor: data.user.dateColor || "#888888",
|
||
taskColor: data.user.taskColor || "#333333",
|
||
todayHighlightColor: data.user.todayHighlightColor || "#f0fafa",
|
||
hourLabelFormat: data.user.hourLabelFormat || "short",
|
||
showSubHourSlots: data.user.showSubHourSlots !== undefined ? data.user.showSubHourSlots : true,
|
||
allDayPosition: data.user.allDayPosition || "below",
|
||
showTaskCheckboxes: data.user.showTaskCheckboxes || false,
|
||
startDayOffset: data.user.startDayOffset !== undefined ? data.user.startDayOffset : 0,
|
||
});
|
||
|
||
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 ViewStyle);
|
||
if (data.user.fontSize)
|
||
setFontSize(data.user.fontSize as "S" | "M" | "L");
|
||
if (data.user.focusTimerDuration)
|
||
setFocusTimerDuration(data.user.focusTimerDuration);
|
||
if (data.user.showSchedule !== undefined)
|
||
setShowSchedule(data.user.showSchedule);
|
||
if (data.user.focusBreakDuration)
|
||
setFocusBreakDuration(data.user.focusBreakDuration);
|
||
|
||
// Apply the initial start date offset if set
|
||
if (data.user.startDayOffset !== undefined && data.user.startDayOffset !== 0) {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
d.setDate(d.getDate() + data.user.startDayOffset);
|
||
setCurrentWeekStart(d);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setIsLoading(false);
|
||
// Enable live preview after initial load
|
||
setTimeout(() => { profileLoadedRef.current = true; }, 100);
|
||
}
|
||
}
|
||
|
||
const handleGoogleConnect = () => {
|
||
window.location.href = "/api/calendar/google/start";
|
||
};
|
||
|
||
// --- Apple Calendar (CalDAV) handlers ---
|
||
const handleAppleCalendarConnect = () => {
|
||
setShowAppleCalendarModal(true);
|
||
setAppleCalError("");
|
||
setAppleCalEmail("");
|
||
setAppleCalPassword("");
|
||
};
|
||
|
||
const submitAppleCalendarConnection = async () => {
|
||
if (!appleCalEmail || !appleCalPassword) {
|
||
setAppleCalError("Please enter both email and app-specific password.");
|
||
return;
|
||
}
|
||
|
||
setIsConnectingAppleCal(true);
|
||
setAppleCalError("");
|
||
|
||
try {
|
||
const response = await fetch("/api/calendar/apple/connect", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
email: appleCalEmail,
|
||
password: appleCalPassword,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || "Failed to connect Apple Calendar");
|
||
}
|
||
|
||
setShowAppleCalendarModal(false);
|
||
showConnMsg("success", "Apple Calendar connected successfully!");
|
||
setTimeout(() => { window.location.href = window.location.pathname + "?calendar=apple_connected&openSettings=calendars"; }, 1200);
|
||
} catch (err: any) {
|
||
setAppleCalError(err.message || "Connection failed");
|
||
} finally {
|
||
setIsConnectingAppleCal(false);
|
||
}
|
||
};
|
||
|
||
// --- Synology Calendar handlers ---
|
||
const handleSynologyCalendarConnect = () => {
|
||
setShowSynologyCalendarModal(true);
|
||
setSynologyCalError("");
|
||
setSynologyCalServerUrl("");
|
||
setSynologyCalUsername("");
|
||
setSynologyCalPassword("");
|
||
};
|
||
|
||
const submitSynologyCalendarConnection = async () => {
|
||
if (!synologyCalServerUrl || !synologyCalUsername || !synologyCalPassword) {
|
||
setSynologyCalError("Please enter Server URL, username, and password.");
|
||
return;
|
||
}
|
||
|
||
setIsConnectingSynologyCal(true);
|
||
setSynologyCalError("");
|
||
|
||
try {
|
||
const response = await fetch("/api/calendar/synology/connect", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
serverUrl: synologyCalServerUrl,
|
||
username: synologyCalUsername,
|
||
password: synologyCalPassword,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || "Failed to connect Synology Calendar");
|
||
}
|
||
|
||
setShowSynologyCalendarModal(false);
|
||
showConnMsg("success", "Synology Calendar connected successfully!");
|
||
setTimeout(() => { window.location.href = window.location.pathname + "?calendar=synology_connected&openSettings=calendars"; }, 1200);
|
||
} catch (err: any) {
|
||
setSynologyCalError(err.message || "Connection failed");
|
||
} finally {
|
||
setIsConnectingSynologyCal(false);
|
||
}
|
||
};
|
||
|
||
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,
|
||
dateAlignment: profile.dateAlignment,
|
||
showTimeGrid: showTimeGrid,
|
||
cellDuration: cellDuration,
|
||
viewStyle: viewStyle,
|
||
showNextTask: showNextTask,
|
||
showSomeday: showSomeday,
|
||
showAllDayEvents: showAllDay,
|
||
showSchedule: showSchedule,
|
||
// The following will be taken from profile if present,
|
||
// ensuring edited state is saved.
|
||
// Validate numeric fields to avoid NaN
|
||
focusBreakDuration: !isNaN(Number(profile.focusBreakDuration))
|
||
? Number(profile.focusBreakDuration)
|
||
: focusBreakDuration || 5,
|
||
focusTimerDuration: !isNaN(Number(profile.focusTimerDuration))
|
||
? Number(profile.focusTimerDuration)
|
||
: focusTimerDuration || 25,
|
||
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: showTimeGrid,
|
||
cellDuration: cellDuration,
|
||
viewStyle: viewStyle,
|
||
language: profile.language || "de",
|
||
dateFormat: profile.dateFormat || "MM/dd/yyyy",
|
||
timeFormat: profile.timeFormat || "12h",
|
||
startHour: profile.startHour || 8,
|
||
endHour: profile.endHour || 18,
|
||
fontSize: fontSize,
|
||
showNextTask: showNextTask,
|
||
showSomeday: showSomeday,
|
||
showAllDayEvents: showAllDay,
|
||
showSchedule: showSchedule,
|
||
headlineFont: headlineFont,
|
||
headlineFontSize: headlineFontSize,
|
||
headlineFontWeight: headlineFontWeight,
|
||
dateFontFamily: dateFontFamily,
|
||
dateFontSize: dateFontSize,
|
||
dateFontWeight: dateFontWeight,
|
||
timeTaskFontFamily: timeTaskFontFamily,
|
||
timeTaskFontSize: timeTaskFontSize,
|
||
timeTaskFontWeight: timeTaskFontWeight,
|
||
bodyFont: bodyFont,
|
||
taskFontFamily: taskFontFamily,
|
||
taskFontSize: taskFontSize,
|
||
taskFontWeight: taskFontWeight,
|
||
fontWeight: fontWeight,
|
||
weekendColorSat: weekendColorSat,
|
||
weekendColorSun: weekendColorSun,
|
||
weekdayColor: profile.weekdayColor,
|
||
dateColor: profile.dateColor,
|
||
taskColor: profile.taskColor,
|
||
todayHighlightColor: profile.todayHighlightColor,
|
||
autoRolling: profile.autoRolling,
|
||
protectEventTimes: profile.protectEventTimes || protectEventTimes,
|
||
focusTimerDuration:
|
||
profile.focusTimerDuration || focusTimerDuration,
|
||
focusBreakDuration:
|
||
profile.focusBreakDuration || focusBreakDuration,
|
||
pastDayColor: profile.pastDayColor,
|
||
goalScope: profile.goalScope,
|
||
dateLayout: profile.dateLayout,
|
||
mobileDateLayout: profile.mobileDateLayout,
|
||
dateAlignment: profile.dateAlignment,
|
||
startDayOffset: profile.startDayOffset,
|
||
} as any);
|
||
}
|
||
|
||
if (profile.focusTimerDuration && setFocusTimerDuration) {
|
||
setFocusTimerDuration(profile.focusTimerDuration);
|
||
}
|
||
if (profile.focusBreakDuration && setFocusBreakDuration) {
|
||
setFocusBreakDuration(profile.focusBreakDuration);
|
||
}
|
||
|
||
// Temporary success message
|
||
setTimeout(() => setAccountMsg(""), 3000);
|
||
} else {
|
||
console.error("Failed to update profile:", data);
|
||
setAccountMsg(
|
||
data.details
|
||
? `${data.error}: ${data.details}`
|
||
: data.error || "Failed to update profile",
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.error("Error updating profile:", e);
|
||
setAccountMsg("Error updating profile");
|
||
}
|
||
};
|
||
|
||
const handleDownloadData = () => {
|
||
window.open("/api/user/export", "_blank");
|
||
};
|
||
|
||
const handleExportAllData = async () => {
|
||
setIsExportingAll(true);
|
||
try {
|
||
const res = await fetch("/api/user/export-data");
|
||
if (!res.ok) throw new Error("Export failed");
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `weekly_todo_backup_${new Date().toISOString().split("T")[0]}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} catch (e) {
|
||
console.error("Export error:", e);
|
||
} finally {
|
||
setIsExportingAll(false);
|
||
}
|
||
};
|
||
|
||
const handleImportData = async () => {
|
||
if (!importFile) return;
|
||
|
||
if (importMode === "replace") {
|
||
const confirmed = confirm(t.importConfirmReplace);
|
||
if (!confirmed) return;
|
||
}
|
||
|
||
setIsImporting(true);
|
||
setImportMsg("");
|
||
|
||
try {
|
||
const text = await importFile.text();
|
||
JSON.parse(text); // validate JSON
|
||
|
||
const res = await fetch(`/api/user/import-data?mode=${importMode}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: text,
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (!res.ok) {
|
||
setImportMsg(`❌ ${data.error || "Import failed"}`);
|
||
return;
|
||
}
|
||
|
||
const { imported } = data;
|
||
const parts: string[] = [];
|
||
if (imported.tasks > 0) parts.push(`${imported.tasks} ${profile.language === "de" ? "Aufgaben" : "tasks"}`);
|
||
if (imported.somedayLists > 0) parts.push(`${imported.somedayLists} ${profile.language === "de" ? "Listen" : "lists"}`);
|
||
if (imported.projects > 0) parts.push(`${imported.projects} ${profile.language === "de" ? "Projekte" : "projects"}`);
|
||
|
||
setImportMsg(`✓ ${profile.language === "de" ? "Importiert" : "Imported"}: ${parts.join(", ")}`);
|
||
setImportFile(null);
|
||
|
||
// Reset file input
|
||
const fileInput = document.getElementById("import-file-input") as HTMLInputElement;
|
||
if (fileInput) fileInput.value = "";
|
||
|
||
// Reload to reflect imported data
|
||
setTimeout(() => window.location.reload(), 1500);
|
||
} catch (e) {
|
||
setImportMsg(`❌ ${profile.language === "de" ? "Ungültige JSON-Datei" : "Invalid JSON file"}`);
|
||
} finally {
|
||
setIsImporting(false);
|
||
}
|
||
};
|
||
|
||
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 ${isVisible ? "show" : ""}`}
|
||
onClick={handleClose}
|
||
style={{ zIndex: 1999 }}
|
||
/>
|
||
<div className={`weekly-settings-sidebar ${isVisible ? "open" : ""}`}>
|
||
<header className="weekly-settings-header">
|
||
<h2 className="weekly-settings-title">{t.settings}</h2>
|
||
<button className="weekly-settings-close" onClick={handleClose}>
|
||
×
|
||
</button>
|
||
</header>
|
||
|
||
<div
|
||
className="weekly-settings-tabs"
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "center",
|
||
flexWrap: "wrap",
|
||
gap: "4px",
|
||
borderBottom: "1px solid var(--weekly-border, #eee)",
|
||
padding: "0 24px",
|
||
}}
|
||
>
|
||
{([
|
||
{ key: "general", icon: <Settings size={18} />, label: t.general },
|
||
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
|
||
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
|
||
{ key: "account", icon: <User size={18} />, label: t.account },
|
||
{ key: "styling", icon: <Palette size={18} />, label: t.styling },
|
||
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
|
||
{ key: "about", icon: <Info size={18} />, label: t.about },
|
||
] as const).map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setActiveTab(tab.key as any)}
|
||
title={tab.label}
|
||
className="settings-tab-btn"
|
||
style={{
|
||
padding: "10px 14px",
|
||
borderBottom:
|
||
activeTab === tab.key
|
||
? "2px solid var(--weekly-text, black)"
|
||
: "2px solid transparent",
|
||
background: "none",
|
||
border: "none",
|
||
borderBottomStyle: "solid",
|
||
borderBottomWidth: "2px",
|
||
borderBottomColor:
|
||
activeTab === tab.key
|
||
? "var(--weekly-text, black)"
|
||
: "transparent",
|
||
cursor: "pointer",
|
||
opacity: activeTab === tab.key ? 1 : 0.5,
|
||
color: "var(--weekly-text, #333)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
transition: "opacity 0.15s, border-color 0.15s",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
{tab.icon}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div
|
||
className="weekly-settings-content"
|
||
style={{ flex: 1, overflowY: "auto", padding: "24px" }}
|
||
>
|
||
{activeTab === "general" ? (
|
||
<div
|
||
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
|
||
>
|
||
|
||
<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>
|
||
|
||
{showAllDay && (
|
||
<div
|
||
style={{
|
||
marginLeft: "24px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "8px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
{t.allDayPosition}
|
||
</label>
|
||
<select
|
||
value={allDayPosition}
|
||
onChange={(e) => {
|
||
const pos = e.target.value as "above" | "below";
|
||
setAllDayPosition(pos);
|
||
saveSetting("allDayPosition", pos);
|
||
}}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
border: "1px solid #ddd",
|
||
borderRadius: "4px",
|
||
}}
|
||
>
|
||
<option value="above">{t.allDayAbove}</option>
|
||
<option value="below">{t.allDayBelow}</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
id="showSchedule"
|
||
checked={showSchedule}
|
||
onChange={(e) => setShowSchedule(e.target.checked)}
|
||
style={{ width: "16px", height: "16px" }}
|
||
/>
|
||
<label
|
||
htmlFor="showSchedule"
|
||
style={{ fontSize: "0.9rem", fontWeight: 600 }}
|
||
>
|
||
{t.showScheduleCalendar}
|
||
</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="showTaskCheckboxes"
|
||
checked={profile.showTaskCheckboxes || false}
|
||
onChange={(e) =>
|
||
setProfile({ ...profile, showTaskCheckboxes: e.target.checked })
|
||
}
|
||
style={{ width: "16px", height: "16px" }}
|
||
/>
|
||
<label
|
||
htmlFor="showTaskCheckboxes"
|
||
style={{ fontSize: "0.9rem", fontWeight: 600 }}
|
||
>
|
||
{t.showTaskCheckboxes}
|
||
</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>
|
||
|
||
|
||
{/* 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) || 0,
|
||
}))
|
||
}
|
||
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) || 0,
|
||
}))
|
||
}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
border: "1px solid #ddd",
|
||
borderRadius: "4px",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hour Label Format */}
|
||
<div style={{ marginTop: "4px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
{t.hourLabelFormat}
|
||
</label>
|
||
<select
|
||
value={hourLabelFormat}
|
||
onChange={(e) => {
|
||
const fmt = e.target.value as "short" | "full";
|
||
setHourLabelFormat(fmt);
|
||
saveSetting("hourLabelFormat", fmt);
|
||
}}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
border: "1px solid #ddd",
|
||
borderRadius: "4px",
|
||
}}
|
||
>
|
||
<option value="short">{t.hourLabelShort}</option>
|
||
<option value="full">{t.hourLabelFull}</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Sub-hour Slot Labels */}
|
||
<div
|
||
style={{ display: "flex", alignItems: "center", gap: "8px", marginTop: "8px" }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
id="showSubHourSlots"
|
||
checked={showSubHourSlots}
|
||
onChange={(e) => {
|
||
setShowSubHourSlots(e.target.checked);
|
||
saveSetting("showSubHourSlots", e.target.checked);
|
||
}}
|
||
style={{ width: "16px", height: "16px" }}
|
||
/>
|
||
<label
|
||
htmlFor="showSubHourSlots"
|
||
style={{ fontSize: "0.9rem", fontWeight: 600 }}
|
||
>
|
||
{t.showSubhourLabels}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
|
||
|
||
<div style={{ marginTop: "16px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.viewStyle}
|
||
</label>
|
||
<div
|
||
className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1"
|
||
style={{ width: "fit-content" }}
|
||
>
|
||
<button
|
||
onClick={() => {
|
||
setViewStyle("simple");
|
||
setShowTimeGrid(true);
|
||
saveSetting("viewStyle", "simple");
|
||
saveSetting("showTimeGrid", true);
|
||
}}
|
||
className={`px-4 py-2 text-sm rounded transition-colors ${showTimeGrid && viewStyle === "simple" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
>
|
||
{t.simpleView}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setViewStyle("calendar");
|
||
setShowTimeGrid(true);
|
||
saveSetting("viewStyle", "calendar");
|
||
saveSetting("showTimeGrid", true);
|
||
}}
|
||
className={`px-4 py-2 text-sm rounded transition-colors ${showTimeGrid && viewStyle === "calendar" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
>
|
||
{t.calendarView}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setViewStyle("list");
|
||
setShowTimeGrid(false);
|
||
saveSetting("viewStyle", "list");
|
||
saveSetting("showTimeGrid", false);
|
||
}}
|
||
className={`px-4 py-2 text-sm rounded transition-colors ${!showTimeGrid && viewStyle === "list" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
>
|
||
{t.listView}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setViewStyle("kanban");
|
||
saveSetting("viewStyle", "kanban");
|
||
}}
|
||
className={`px-4 py-2 text-sm rounded transition-colors ${viewStyle === "kanban" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||
>
|
||
{t.kanbanView}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Kanban Stages Settings */}
|
||
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
|
||
<h4 style={{ fontSize: "0.95rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "6px" }}>
|
||
<LayoutGrid size={16} /> {t.kanbanStages}
|
||
</h4>
|
||
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>{t.kanbanStagesDesc}</p>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
|
||
{kanbanStages.map((stage, idx) => (
|
||
<div key={stage.id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "4px 8px", borderRadius: "6px", background: "var(--bg-secondary, #f9fafb)" }}>
|
||
<input
|
||
type="color"
|
||
value={stage.color}
|
||
onChange={(e) => {
|
||
const updated = kanbanStages.map((s, i) => i === idx ? { ...s, color: e.target.value } : s);
|
||
saveKanbanStages(updated);
|
||
}}
|
||
style={{ width: "24px", height: "24px", border: "none", cursor: "pointer", padding: 0 }}
|
||
/>
|
||
<input
|
||
type="text"
|
||
defaultValue={stage.name}
|
||
onBlur={(e) => {
|
||
const updated = kanbanStages.map((s, i) => i === idx ? { ...s, name: e.target.value } : s);
|
||
saveKanbanStages(updated);
|
||
}}
|
||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "4px 8px", fontSize: "0.85rem" }}
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
const updated = kanbanStages.filter((_, i) => i !== idx);
|
||
saveKanbanStages(updated);
|
||
}}
|
||
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none" }}
|
||
title="Delete"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
const id = `stage-${Date.now()}`;
|
||
saveKanbanStages([...kanbanStages, { id, name: t.stageName, color: "#6b7280" }]);
|
||
}}
|
||
style={{
|
||
display: "flex", alignItems: "center", gap: "4px",
|
||
background: "none", border: "1px dashed #ccc", borderRadius: "6px",
|
||
padding: "6px 12px", cursor: "pointer", color: "#888", fontSize: "0.85rem",
|
||
}}
|
||
>
|
||
<Plus size={14} /> {t.addStage}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Projects Section */}
|
||
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
|
||
<h4 style={{ fontSize: "0.95rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "6px" }}>
|
||
<FolderOpen size={16} /> {t.projects}
|
||
</h4>
|
||
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>{t.projectsDesc}</p>
|
||
{projects.length === 0 && (
|
||
<p style={{ fontSize: "0.85rem", color: "#aaa", fontStyle: "italic", marginBottom: "8px" }}>{t.noProjects}</p>
|
||
)}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
|
||
{projects.map((p) => (
|
||
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "4px 8px", borderRadius: "6px", background: "var(--bg-secondary, #f9fafb)" }}>
|
||
{editingProjectId === p.id ? (
|
||
<>
|
||
<input
|
||
type="color"
|
||
value={editProjectColor}
|
||
onChange={(e) => setEditProjectColor(e.target.value)}
|
||
style={{ width: "24px", height: "24px", border: "none", cursor: "pointer", padding: 0 }}
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={editProjectName}
|
||
onChange={(e) => setEditProjectName(e.target.value)}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "4px 8px", fontSize: "0.85rem" }}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
fetch("/api/projects", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor }),
|
||
}).then(() => { onProjectsChanged(); setEditingProjectId(null); });
|
||
}
|
||
if (e.key === "Escape") setEditingProjectId(null);
|
||
}}
|
||
autoFocus
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
fetch("/api/projects", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor }),
|
||
}).then(() => { onProjectsChanged(); setEditingProjectId(null); });
|
||
}}
|
||
style={{ padding: "2px 6px", fontSize: "0.8rem" }}
|
||
className="weekly-btn-primary"
|
||
>
|
||
<Check size={12} />
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Circle size={14} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
|
||
<span style={{ flex: 1, fontSize: "0.85rem", fontWeight: 500 }}>{p.name}</span>
|
||
<button
|
||
onClick={() => { setEditingProjectId(p.id); setEditProjectName(p.name); setEditProjectColor(p.color || "#999"); }}
|
||
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", background: "none", border: "none" }}
|
||
title="Edit"
|
||
>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(profile.language === "de" ? `Projekt "${p.name}" löschen?` : `Delete project "${p.name}"?`)) {
|
||
fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged());
|
||
}
|
||
}}
|
||
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none" }}
|
||
title="Delete"
|
||
>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
|
||
<input
|
||
type="color"
|
||
value={newProjectColor}
|
||
onChange={(e) => setNewProjectColor(e.target.value)}
|
||
style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0 }}
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={newProjectName}
|
||
onChange={(e) => setNewProjectName(e.target.value)}
|
||
placeholder={t.projectName}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && newProjectName.trim()) {
|
||
fetch("/api/projects", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor }),
|
||
}).then(() => { onProjectsChanged(); setNewProjectName(""); });
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
if (!newProjectName.trim()) return;
|
||
fetch("/api/projects", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor }),
|
||
}).then(() => { onProjectsChanged(); setNewProjectName(""); });
|
||
}}
|
||
className="weekly-btn-primary"
|
||
style={{ padding: "6px 12px", fontSize: "0.8rem", whiteSpace: "nowrap" }}
|
||
>
|
||
<Plus size={14} /> {t.addProject}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
marginTop: "16px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "12px",
|
||
}}
|
||
>
|
||
<button
|
||
onClick={handleUpdateProfile}
|
||
className="weekly-btn-primary"
|
||
style={{ padding: "10px 20px" }}
|
||
>
|
||
{t.saveChanges}
|
||
</button>
|
||
{accountMsg && (
|
||
<span
|
||
style={{
|
||
fontSize: "0.9rem",
|
||
color: accountMsg.toLowerCase().includes("success")
|
||
? "#059669"
|
||
: "#dc2626",
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{accountMsg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : activeTab === "localisation" ? (
|
||
<div
|
||
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
|
||
>
|
||
<h4 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
|
||
{t.localisation || "Localisation"}
|
||
</h4>
|
||
|
||
{/* Start Week Setting + Start View On */}
|
||
<div style={{ display: "flex", gap: "24px", flexWrap: "wrap" }}>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
{t.weekStartLabel}
|
||
</label>
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<button
|
||
className={`px-3 py-2 rounded text-sm ${weekStartDay === 1 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"}`}
|
||
onClick={() => setWeekStartDay(1)}
|
||
>
|
||
{t.monday}
|
||
</button>
|
||
<button
|
||
className={`px-3 py-2 rounded text-sm ${weekStartDay === 0 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"}`}
|
||
onClick={() => setWeekStartDay(0)}
|
||
>
|
||
{t.sunday}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
{t.startViewLabel}
|
||
</label>
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<button
|
||
className={`px-3 py-2 rounded text-sm ${(profile.startDayOffset || 0) === 0 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"}`}
|
||
onClick={() => {
|
||
setProfile({ ...profile, startDayOffset: 0 });
|
||
saveSetting("startDayOffset", 0);
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
setCurrentWeekStart(d);
|
||
}}
|
||
>
|
||
{t.today}
|
||
</button>
|
||
<button
|
||
className={`px-3 py-2 rounded text-sm ${profile.startDayOffset === -1 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"}`}
|
||
onClick={() => {
|
||
setProfile({ ...profile, startDayOffset: -1 });
|
||
saveSetting("startDayOffset", -1);
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
d.setDate(d.getDate() - 1);
|
||
setCurrentWeekStart(d);
|
||
}}
|
||
>
|
||
{t.yesterday}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Weekday Format */}
|
||
<div style={{ marginTop: "8px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.weekdayFormat || translations["en"].weekdayFormat}
|
||
</label>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
<select
|
||
value={profile.weekdayFormat || "long"}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
weekdayFormat: e.target.value as any,
|
||
})
|
||
}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
<option value="long">{t.weekdayFormatFull || translations["en"].weekdayFormatFull}</option>
|
||
<option value="short">{t.weekdayFormatShort || translations["en"].weekdayFormatShort}</option>
|
||
<option value="narrow">{t.weekdayFormatNarrow || translations["en"].weekdayFormatNarrow}</option>
|
||
<option value="custom">{t.weekdayFormatCustom || translations["en"].weekdayFormatCustom}</option>
|
||
</select>
|
||
|
||
{profile.weekdayFormat === "custom" && (
|
||
<input
|
||
type="text"
|
||
value={profile.customWeekdayNames || ""}
|
||
onChange={(e) => setProfile({ ...profile, customWeekdayNames: e.target.value })}
|
||
placeholder={
|
||
weekStartDay === 1
|
||
? (t.customWeekdayNamesMon || translations["en"].customWeekdayNamesMon)
|
||
: (t.customWeekdayNamesSun || translations["en"].customWeekdayNamesSun)
|
||
}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Weekday Case */}
|
||
<div style={{ marginTop: "8px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.weekdayCase || "Weekday Case"}
|
||
</label>
|
||
<select
|
||
value={profile.weekdayCase || "capitalize"}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
weekdayCase: e.target.value as "normal" | "capitalize" | "uppercase",
|
||
})
|
||
}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
<option value="normal">{t.weekdayCaseNormal || "Normal (monday)"}</option>
|
||
<option value="capitalize">{t.weekdayCaseCapitalize || "Capitalize (Monday)"}</option>
|
||
<option value="uppercase">{t.weekdayCaseUppercase || "Uppercase (MONDAY)"}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ marginTop: "8px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
{t.language}
|
||
</label>
|
||
<select
|
||
value={profile.language || "de"}
|
||
onChange={(e) =>
|
||
setProfile((p) => ({ ...p, 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">🇩🇪 Deutsch</option>
|
||
<option value="fr">🇫🇷 Français</option>
|
||
<option value="es">🇪🇸 Español</option>
|
||
<option value="it">🇮🇹 Italiano</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>
|
||
|
||
<div />
|
||
</div>
|
||
) : activeTab === "calendar" ? (
|
||
isLoading ? (
|
||
<p>Loading connections...</p>
|
||
) : (
|
||
<>
|
||
<h3
|
||
style={{
|
||
marginBottom: "1rem",
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{t.connectedCalendars}
|
||
</h3>
|
||
|
||
{connMsg && (
|
||
<div
|
||
style={{
|
||
padding: "8px 12px",
|
||
borderRadius: "4px",
|
||
marginBottom: "12px",
|
||
fontSize: "0.875rem",
|
||
background:
|
||
connMsg.type === "success"
|
||
? "rgba(16, 185, 129, 0.1)"
|
||
: "rgba(239, 68, 68, 0.1)",
|
||
color: connMsg.type === "success" ? "#059669" : "#dc2626",
|
||
border: `1px solid ${connMsg.type === "success" ? "#10b981" : "#ef4444"}`,
|
||
}}
|
||
>
|
||
{connMsg.text}
|
||
</div>
|
||
)}
|
||
|
||
{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={{
|
||
marginBottom: "0.5rem",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontWeight: 600,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "8px",
|
||
}}
|
||
>
|
||
<span>
|
||
{conn.provider === "google"
|
||
? <FontAwesomeIcon icon={faGoogle} />
|
||
: conn.provider === "apple"
|
||
? <FontAwesomeIcon icon={faApple} />
|
||
: conn.provider === "synology"
|
||
? <FontAwesomeIcon icon={faServer} />
|
||
: <FontAwesomeIcon icon={faMicrosoft} />}
|
||
</span>
|
||
{conn.provider === "google"
|
||
? "Google Calendar"
|
||
: conn.provider === "apple"
|
||
? "Apple Calendar"
|
||
: conn.provider === "synology"
|
||
? "Synology Calendar"
|
||
: "Outlook Calendar"}
|
||
</div>
|
||
{confirmDisconnectId === conn.id ? (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "6px",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: "0.8rem",
|
||
color: "var(--weekly-text)",
|
||
}}
|
||
>
|
||
Sure?
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setConfirmDisconnectId(null);
|
||
setDisconnectingId(conn.id);
|
||
try {
|
||
await onRemoveConnection(conn.id);
|
||
showConnMsg(
|
||
"success",
|
||
"Calendar disconnected.",
|
||
);
|
||
} catch (err: any) {
|
||
console.error("Failed to disconnect:", err);
|
||
showConnMsg(
|
||
"error",
|
||
err.message ||
|
||
"Failed to disconnect calendar",
|
||
);
|
||
} finally {
|
||
setDisconnectingId(null);
|
||
}
|
||
}}
|
||
style={{
|
||
padding: "3px 8px",
|
||
fontSize: "0.8rem",
|
||
background: "#dc2626",
|
||
color: "white",
|
||
border: "none",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
Yes
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setConfirmDisconnectId(null);
|
||
}}
|
||
style={{
|
||
padding: "3px 8px",
|
||
fontSize: "0.8rem",
|
||
background: "#e5e7eb",
|
||
color: "#374151",
|
||
border: "none",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
No
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setConfirmDisconnectId(conn.id);
|
||
}}
|
||
disabled={disconnectingId === conn.id}
|
||
style={{
|
||
padding: "4px 8px",
|
||
fontSize: "0.8rem",
|
||
color:
|
||
disconnectingId === conn.id
|
||
? "#999"
|
||
: "#dc2626",
|
||
background: "none",
|
||
border: `1px solid ${disconnectingId === conn.id ? "#999" : "#dc2626"}`,
|
||
borderRadius: "4px",
|
||
cursor:
|
||
disconnectingId === conn.id
|
||
? "not-allowed"
|
||
: "pointer",
|
||
opacity: disconnectingId === conn.id ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{disconnectingId === conn.id
|
||
? "Disconnecting..."
|
||
: "Disconnect"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Calendar Event Selection List */}
|
||
{conn.calendars &&
|
||
Array.isArray(conn.calendars) &&
|
||
conn.calendars.length > 0 ? (
|
||
<div style={{ paddingLeft: "8px" }}>
|
||
{/* Column Headers */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "8px",
|
||
marginBottom: "6px",
|
||
paddingBottom: "4px",
|
||
borderBottom: "1px solid var(--weekly-border, #eee)",
|
||
}}
|
||
>
|
||
<span style={{ flex: 1, fontSize: "0.75rem", color: "var(--weekly-text-light)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||
Calendar
|
||
</span>
|
||
<span style={{ width: "50px", textAlign: "center", fontSize: "0.75rem", color: "var(--weekly-text-light)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||
Display
|
||
</span>
|
||
<span style={{ width: "50px", textAlign: "center", fontSize: "0.75rem", color: "var(--weekly-text-light)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||
Edit
|
||
</span>
|
||
</div>
|
||
{/* Calendar Rows */}
|
||
{conn.calendars.map((cal: any) => {
|
||
const isShared = /⚠/.test(cal.title);
|
||
const cleanTitle = cal.title
|
||
.replace(/\s*⚠️?\s*/g, "")
|
||
.trim();
|
||
return (
|
||
<div
|
||
key={cal.id}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "8px",
|
||
padding: "3px 0",
|
||
}}
|
||
>
|
||
{/* Calendar Name */}
|
||
<span
|
||
style={{
|
||
flex: 1,
|
||
fontSize: "0.9rem",
|
||
color: "var(--weekly-text)",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{cleanTitle}
|
||
{isShared && (
|
||
<span title="Shared calendar" style={{ marginLeft: "4px", fontSize: "0.75rem", opacity: 0.5 }}>
|
||
🔗
|
||
</span>
|
||
)}
|
||
{cal.isPrimary && (
|
||
<span style={{ fontSize: "0.8em", color: "var(--weekly-text-light)", marginLeft: "4px" }}>
|
||
(Primary)
|
||
</span>
|
||
)}
|
||
</span>
|
||
|
||
{/* Display checkbox */}
|
||
<span style={{ width: "50px", textAlign: "center" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={cal.selected !== false}
|
||
onChange={(e) =>
|
||
handleUpdateCalendar(conn.id, cal.id, {
|
||
selected: e.target.checked,
|
||
})
|
||
}
|
||
style={{ cursor: "pointer" }}
|
||
/>
|
||
</span>
|
||
|
||
{/* Edit checkbox */}
|
||
<span style={{ width: "50px", textAlign: "center" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={cal.editable === true}
|
||
onChange={(e) =>
|
||
handleUpdateCalendar(conn.id, cal.id, {
|
||
editable: e.target.checked,
|
||
})
|
||
}
|
||
style={{ cursor: "pointer" }}
|
||
title="Allow adding/editing events"
|
||
/>
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div
|
||
style={{
|
||
fontSize: "0.85rem",
|
||
color: "#888",
|
||
paddingLeft: "24px",
|
||
}}
|
||
>
|
||
{conn.provider === "google"
|
||
? t.noCalendarsFound
|
||
: conn.provider === "apple"
|
||
? t.noCalendarsApple
|
||
: conn.provider === "synology"
|
||
? t.noCalendarsSynology
|
||
: t.selectionAfterConnect}
|
||
</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<h3
|
||
style={{
|
||
marginBottom: "1rem",
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{t.connectMore}
|
||
</h3>
|
||
|
||
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
|
||
<button
|
||
onClick={handleGoogleConnect}
|
||
className="calendar-connect-btn"
|
||
>
|
||
<FontAwesomeIcon icon={faGoogle} className="mr-2" /> {t.connectGoogle}
|
||
</button>
|
||
<button
|
||
onClick={handleAppleCalendarConnect}
|
||
className="calendar-connect-btn"
|
||
>
|
||
<FontAwesomeIcon icon={faApple} className="mr-2" /> {t.connectApple}
|
||
</button>
|
||
<button
|
||
onClick={handleOutlookConnect}
|
||
className="calendar-connect-btn"
|
||
>
|
||
<FontAwesomeIcon icon={faMicrosoft} className="mr-2" /> {t.connectOutlook}
|
||
</button>
|
||
<button
|
||
onClick={handleSynologyCalendarConnect}
|
||
className="calendar-connect-btn"
|
||
>
|
||
<FontAwesomeIcon icon={faServer} className="mr-2" /> {t.connectSynology || "Connect Synology"}
|
||
</button>
|
||
</div>
|
||
|
||
<h3
|
||
style={{
|
||
marginBottom: "1rem",
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
marginTop: "2rem",
|
||
}}
|
||
>
|
||
{t.syncTasks}
|
||
</h3>
|
||
<p
|
||
style={{
|
||
fontSize: "0.9rem",
|
||
color: "var(--weekly-text-light)",
|
||
marginBottom: "1rem",
|
||
}}
|
||
>
|
||
{t.syncTasksDesc}
|
||
</p>
|
||
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "1.5rem",
|
||
}}
|
||
>
|
||
{connections
|
||
.filter((c) => ["google", "outlook", "synology"].includes(c.provider))
|
||
.map((conn) => {
|
||
const providerLists =
|
||
availableTaskLists[
|
||
conn.provider as "google" | "outlook" | "synology"
|
||
] || [];
|
||
const isFetching =
|
||
isFetchingProviderLists[conn.provider];
|
||
|
||
return (
|
||
<div key={conn.id}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "8px",
|
||
marginBottom: "0.5rem",
|
||
fontWeight: 600,
|
||
fontSize: "0.9rem",
|
||
}}
|
||
>
|
||
<span>
|
||
{conn.provider === "google" ? <FontAwesomeIcon icon={faGoogle} /> : conn.provider === "synology" ? <FontAwesomeIcon icon={faServer} /> : <FontAwesomeIcon icon={faMicrosoft} />}
|
||
</span>
|
||
{conn.provider === "google"
|
||
? "Google Tasks"
|
||
: conn.provider === "synology"
|
||
? "Synology Tasks"
|
||
: "Microsoft To-Do"}
|
||
{isFetching && (
|
||
<span
|
||
style={{
|
||
fontSize: "0.75rem",
|
||
fontWeight: 400,
|
||
color: "#888",
|
||
}}
|
||
>
|
||
(fetching lists...)
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "4px",
|
||
}}
|
||
>
|
||
{/* Column header with sync all / unsync all */}
|
||
{providerLists.length > 0 && (() => {
|
||
const allSynced = providerLists.every(
|
||
(list: { id: string; title: string }) => somedayLists.some(
|
||
(sl: SomedayList) => sl.externalId === list.id && sl.externalProvider === conn.provider,
|
||
),
|
||
);
|
||
const noneSynced = providerLists.every(
|
||
(list: { id: string; title: string }) => !somedayLists.some(
|
||
(sl: SomedayList) => sl.externalId === list.id && sl.externalProvider === conn.provider,
|
||
),
|
||
);
|
||
return (
|
||
<div style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
padding: "2px 8px",
|
||
fontSize: "0.75rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-text-light, #888)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
}}>
|
||
<span style={{ flex: 1 }}>List</span>
|
||
{!allSynced && (
|
||
<button
|
||
onClick={() => handleSyncAll(
|
||
conn.provider as "google" | "outlook" | "synology",
|
||
providerLists,
|
||
true,
|
||
)}
|
||
disabled={importingTasksState}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontSize: "0.7rem",
|
||
color: "var(--weekly-accent, #6366f1)",
|
||
padding: "2px 6px",
|
||
fontWeight: 500,
|
||
textTransform: "none",
|
||
}}
|
||
>{t.syncAll}</button>
|
||
)}
|
||
{!noneSynced && (
|
||
<button
|
||
onClick={() => handleSyncAll(
|
||
conn.provider as "google" | "outlook" | "synology",
|
||
providerLists,
|
||
false,
|
||
)}
|
||
disabled={importingTasksState}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontSize: "0.7rem",
|
||
color: "#ef4444",
|
||
padding: "2px 6px",
|
||
fontWeight: 500,
|
||
textTransform: "none",
|
||
}}
|
||
>{t.unsyncAll}</button>
|
||
)}
|
||
<span style={{ width: "50px", textAlign: "center" }}>Sync</span>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Inline unsync confirmation */}
|
||
{unsyncConfirm && unsyncConfirm.provider === conn.provider && (
|
||
<div style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "8px",
|
||
padding: "8px 10px",
|
||
borderRadius: "6px",
|
||
background: "#fef2f2",
|
||
border: "1px solid #fecaca",
|
||
fontSize: "0.82rem",
|
||
color: "#991b1b",
|
||
}}>
|
||
<span style={{ flex: 1 }}>
|
||
{t.unsyncConfirmMsg.replace("{title}", unsyncConfirm.list.title)}
|
||
</span>
|
||
<button
|
||
onClick={onConfirmUnsync}
|
||
style={{
|
||
background: "#ef4444",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "4px",
|
||
padding: "4px 10px",
|
||
cursor: "pointer",
|
||
fontSize: "0.8rem",
|
||
fontWeight: 600,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>{t.unsyncConfirm}</button>
|
||
<button
|
||
onClick={onCancelUnsync}
|
||
style={{
|
||
background: "none",
|
||
border: "1px solid #d1d5db",
|
||
borderRadius: "4px",
|
||
padding: "4px 10px",
|
||
cursor: "pointer",
|
||
fontSize: "0.8rem",
|
||
color: "#666",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>{t.unsyncCancel}</button>
|
||
</div>
|
||
)}
|
||
|
||
{providerLists.map((list: { id: string; title: string }) => {
|
||
const isSynced = somedayLists.some(
|
||
(sl: SomedayList) =>
|
||
sl.externalId === list.id &&
|
||
sl.externalProvider === conn.provider,
|
||
);
|
||
return (
|
||
<div
|
||
key={list.id}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
padding: "4px 8px",
|
||
borderRadius: "4px",
|
||
background: "rgba(0,0,0,0.02)",
|
||
}}
|
||
>
|
||
<span style={{ flex: 1, fontSize: "0.9rem" }}>
|
||
{list.title}
|
||
</span>
|
||
<span style={{ width: "50px", textAlign: "center" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={isSynced}
|
||
onChange={() =>
|
||
handleToggleTaskList(
|
||
conn.provider as
|
||
| "google"
|
||
| "outlook"
|
||
| "synology",
|
||
list,
|
||
)
|
||
}
|
||
disabled={importingTasksState}
|
||
/>
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
{!isFetching && providerLists.length === 0 && (
|
||
<div
|
||
style={{
|
||
fontSize: "0.85rem",
|
||
color: "#888",
|
||
paddingLeft: "24px",
|
||
}}
|
||
>
|
||
No task lists found.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{connections.filter((c) =>
|
||
["google", "outlook", "synology"].includes(c.provider),
|
||
).length === 0 && (
|
||
<div
|
||
style={{
|
||
fontSize: "0.9rem",
|
||
color: "#888",
|
||
fontStyle: "italic",
|
||
}}
|
||
>
|
||
Connect a provider above to sync task lists.
|
||
</div>
|
||
)}
|
||
|
||
{importStatusMsg && (
|
||
<div
|
||
style={{
|
||
padding: "8px 12px",
|
||
borderRadius: "4px",
|
||
fontSize: "0.9rem",
|
||
background:
|
||
importStatusMsg.type === "success"
|
||
? "rgba(16, 185, 129, 0.1)"
|
||
: "rgba(239, 68, 68, 0.1)",
|
||
color:
|
||
importStatusMsg.type === "success"
|
||
? "#059669"
|
||
: "#dc2626",
|
||
border: `1px solid ${importStatusMsg.type === "success" ? "#10b981" : "#ef4444"}`,
|
||
}}
|
||
>
|
||
{importStatusMsg.text}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)
|
||
) : activeTab === "styling" ? (
|
||
<div
|
||
style={{ display: "flex", flexDirection: "column", gap: "24px" }}
|
||
>
|
||
{/* Typography Settings */}
|
||
<div
|
||
style={{
|
||
marginBottom: "1.5rem",
|
||
borderBottom: "1px solid var(--weekly-border)",
|
||
paddingBottom: "1rem",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "1rem",
|
||
fontWeight: 700,
|
||
marginBottom: "12px",
|
||
color: "var(--weekly-settings-title)",
|
||
}}
|
||
>
|
||
{t.fontCustomization}
|
||
</label>
|
||
|
||
{/* Date Layout & Alignment side-by-side */}
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "1fr 1fr",
|
||
gap: "16px",
|
||
marginBottom: "1.5rem",
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
}}
|
||
>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.dateLayout}
|
||
</label>
|
||
<select
|
||
value={profile.dateLayout || "right"}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
dateLayout: e.target.value as
|
||
| "above"
|
||
| "below"
|
||
| "left"
|
||
| "right"
|
||
| "hidden",
|
||
})
|
||
}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
fontSize: "0.9rem",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
}}
|
||
>
|
||
<option value="right">{t.dateLayoutRight}</option>
|
||
<option value="left">{t.dateLayoutLeft}</option>
|
||
<option value="above">{t.dateLayoutAbove}</option>
|
||
<option value="below">{t.dateLayoutBelow}</option>
|
||
<option value="hidden">{t.dateLayoutHidden}</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.dateLayoutMobile}
|
||
</label>
|
||
<select
|
||
value={profile.mobileDateLayout || "below"}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
mobileDateLayout: e.target.value as
|
||
| "above"
|
||
| "below"
|
||
| "left"
|
||
| "right"
|
||
| "hidden",
|
||
})
|
||
}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
fontSize: "0.9rem",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
}}
|
||
>
|
||
<option value="right">{t.dateLayoutRight}</option>
|
||
<option value="left">{t.dateLayoutLeft}</option>
|
||
<option value="above">{t.dateLayoutAbove}</option>
|
||
<option value="below">{t.dateLayoutBelow}</option>
|
||
<option value="hidden">{t.dateLayoutHidden}</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.dateAlignment || "Date Alignment"}
|
||
</label>
|
||
<select
|
||
value={profile.dateAlignment || "center"}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
dateAlignment: e.target.value as
|
||
| "left"
|
||
| "center"
|
||
| "right"
|
||
| "tight",
|
||
})
|
||
}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
fontSize: "0.9rem",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
}}
|
||
>
|
||
<option value="left">{t.alignmentLeft || "Left"}</option>
|
||
<option value="center">{t.alignmentCenter || "Center"}</option>
|
||
<option value="right">{t.alignmentRight || "Right"}</option>
|
||
<option value="tight">{t.alignmentTight || "Tight"}</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.dateVerticalAlign || "Date Vertical Alignment"}
|
||
</label>
|
||
<select
|
||
value={profile.dateVerticalAlign || "middle"}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
dateVerticalAlign: e.target.value as
|
||
| "top"
|
||
| "middle"
|
||
| "bottom",
|
||
})
|
||
}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
fontSize: "0.9rem",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
}}
|
||
>
|
||
<option value="top">{t.alignTop || "Top"}</option>
|
||
<option value="middle">{t.alignMiddle || "Middle"}</option>
|
||
<option value="bottom">{t.alignBottom || "Bottom"}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{/* Day / Weekday Gap */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.dayWeekdayGap}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={profile.dayHeaderGap || "0.35em"}
|
||
onChange={(e) =>
|
||
setProfile({ ...profile, dayHeaderGap: e.target.value })
|
||
}
|
||
placeholder="0.35em"
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
fontSize: "0.9rem",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Day Names */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.weekdayFont}
|
||
</label>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||
<input
|
||
type="color"
|
||
value={profile.weekdayColor || "#888888"}
|
||
onChange={(e) => setProfile({ ...profile, weekdayColor: e.target.value })}
|
||
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
|
||
/>
|
||
<select
|
||
value={isCustomFont(profile.headlineFont || "") || profile.headlineFont === "__custom__" ? "__custom__" : (profile.headlineFont || "Inter")}
|
||
onChange={(e) =>
|
||
setProfile({ ...profile, headlineFont: e.target.value === "__custom__" ? "__custom__" : e.target.value })
|
||
}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
{AVAILABLE_FONTS.map((font) => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{(isCustomFont(profile.headlineFont || "") || profile.headlineFont === "__custom__") && (
|
||
<input
|
||
type="text"
|
||
value={profile.headlineFont === "__custom__" ? "" : (profile.headlineFont || "")}
|
||
onChange={(e) => setProfile({ ...profile, headlineFont: e.target.value || "__custom__" })}
|
||
placeholder={t.fontPlaceholder}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
)}
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<input
|
||
type="text"
|
||
value={profile.headlineFontSize || "1.25rem"}
|
||
onChange={(e) => setProfile({ ...profile, headlineFontSize: e.target.value })}
|
||
placeholder={t.fontSizePlaceholder}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
<select
|
||
value={profile.headlineFontWeight || "900"}
|
||
onChange={(e) => setProfile({ ...profile, headlineFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
<option value="300">{t.weightLight}</option>
|
||
<option value="400">{t.weightNormal}</option>
|
||
<option value="500">{t.weightMedium}</option>
|
||
<option value="600">{t.weightSemi}</option>
|
||
<option value="700">{t.weightBold}</option>
|
||
<option value="900">{t.weightBlack}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Dates */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.dateFont}
|
||
</label>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||
<input
|
||
type="color"
|
||
value={profile.dateColor || "#888888"}
|
||
onChange={(e) => setProfile({ ...profile, dateColor: e.target.value })}
|
||
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
|
||
/>
|
||
<select
|
||
value={isCustomFont(profile.dateFontFamily || "") || profile.dateFontFamily === "__custom__" ? "__custom__" : (profile.dateFontFamily || "Inter")}
|
||
onChange={(e) => setProfile({ ...profile, dateFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
{AVAILABLE_FONTS.map((font) => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{(isCustomFont(profile.dateFontFamily || "") || profile.dateFontFamily === "__custom__") && (
|
||
<input
|
||
type="text"
|
||
value={profile.dateFontFamily === "__custom__" ? "" : (profile.dateFontFamily || "")}
|
||
onChange={(e) => setProfile({ ...profile, dateFontFamily: e.target.value || "__custom__" })}
|
||
placeholder={t.fontPlaceholder}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
)}
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<input
|
||
type="text"
|
||
value={profile.dateFontSize || "0.65rem"}
|
||
onChange={(e) => setProfile({ ...profile, dateFontSize: e.target.value })}
|
||
placeholder="0.65rem"
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
<select
|
||
value={profile.dateFontWeight || "400"}
|
||
onChange={(e) => setProfile({ ...profile, dateFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
<option value="300">{t.weightLight}</option>
|
||
<option value="400">{t.weightNormal}</option>
|
||
<option value="500">{t.weightMedium}</option>
|
||
<option value="600">{t.weightSemi}</option>
|
||
<option value="700">{t.weightBold}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tasks */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.taskFont}
|
||
</label>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||
<input
|
||
type="color"
|
||
value={profile.taskColor || "#333333"}
|
||
onChange={(e) => setProfile({ ...profile, taskColor: e.target.value })}
|
||
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
|
||
/>
|
||
<select
|
||
value={isCustomFont(profile.taskFontFamily || "") || profile.taskFontFamily === "__custom__" ? "__custom__" : (profile.taskFontFamily || "Inter")}
|
||
onChange={(e) => setProfile({ ...profile, taskFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value, timeTaskFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
{AVAILABLE_FONTS.map((font) => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{(isCustomFont(profile.taskFontFamily || "") || profile.taskFontFamily === "__custom__") && (
|
||
<input
|
||
type="text"
|
||
value={profile.taskFontFamily === "__custom__" ? "" : (profile.taskFontFamily || "")}
|
||
onChange={(e) => setProfile({ ...profile, taskFontFamily: e.target.value || "__custom__", timeTaskFontFamily: e.target.value || "__custom__" })}
|
||
placeholder={t.fontPlaceholder}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
)}
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<input
|
||
type="text"
|
||
value={profile.taskFontSize || "0.9rem"}
|
||
onChange={(e) => setProfile({ ...profile, taskFontSize: e.target.value, timeTaskFontSize: e.target.value })}
|
||
placeholder="0.9rem"
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
<select
|
||
value={profile.taskFontWeight || "400"}
|
||
onChange={(e) => setProfile({ ...profile, taskFontWeight: e.target.value, timeTaskFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
<option value="300">{t.weightLight}</option>
|
||
<option value="400">{t.weightNormal}</option>
|
||
<option value="500">{t.weightMedium}</option>
|
||
<option value="600">{t.weightSemi}</option>
|
||
<option value="700">{t.weightBold}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Calendar Event Font */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
Calendar Event Font
|
||
</label>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
<select
|
||
value={isCustomFont(profile.eventFontFamily || "") || profile.eventFontFamily === "__custom__" ? "__custom__" : (profile.eventFontFamily || "Inter")}
|
||
onChange={(e) => setProfile({ ...profile, eventFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
{AVAILABLE_FONTS.map((font) => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
{(isCustomFont(profile.eventFontFamily || "") || profile.eventFontFamily === "__custom__") && (
|
||
<input
|
||
type="text"
|
||
value={profile.eventFontFamily === "__custom__" ? "" : (profile.eventFontFamily || "")}
|
||
onChange={(e) => setProfile({ ...profile, eventFontFamily: e.target.value || "__custom__" })}
|
||
placeholder={t.fontPlaceholder}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
)}
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<input
|
||
type="text"
|
||
value={profile.eventFontSize || "0.85rem"}
|
||
onChange={(e) => setProfile({ ...profile, eventFontSize: e.target.value })}
|
||
placeholder="0.85rem"
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
<select
|
||
value={profile.eventFontWeight || "400"}
|
||
onChange={(e) => setProfile({ ...profile, eventFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
<option value="300">{t.weightLight}</option>
|
||
<option value="400">{t.weightNormal}</option>
|
||
<option value="500">{t.weightMedium}</option>
|
||
<option value="600">{t.weightSemi}</option>
|
||
<option value="700">{t.weightBold}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Goal Font */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
Goal Font
|
||
</label>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
<select
|
||
value={isCustomFont(profile.goalFontFamily || "") || profile.goalFontFamily === "__custom__" ? "__custom__" : (profile.goalFontFamily || "Inter")}
|
||
onChange={(e) => setProfile({ ...profile, goalFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
{AVAILABLE_FONTS.map((font) => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
{(isCustomFont(profile.goalFontFamily || "") || profile.goalFontFamily === "__custom__") && (
|
||
<input
|
||
type="text"
|
||
value={profile.goalFontFamily === "__custom__" ? "" : (profile.goalFontFamily || "")}
|
||
onChange={(e) => setProfile({ ...profile, goalFontFamily: e.target.value || "__custom__" })}
|
||
placeholder={t.fontPlaceholder}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
)}
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<input
|
||
type="text"
|
||
value={profile.goalFontSize || "1rem"}
|
||
onChange={(e) => setProfile({ ...profile, goalFontSize: e.target.value })}
|
||
placeholder="1rem"
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
<select
|
||
value={profile.goalFontWeight || "400"}
|
||
onChange={(e) => setProfile({ ...profile, goalFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
<option value="300">{t.weightLight}</option>
|
||
<option value="400">{t.weightNormal}</option>
|
||
<option value="500">{t.weightMedium}</option>
|
||
<option value="600">{t.weightSemi}</option>
|
||
<option value="700">{t.weightBold}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
{/* Calendar Week Font */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
Calendar Week (KW)
|
||
</label>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||
<input
|
||
type="color"
|
||
value={profile.cwColor || "#333333"}
|
||
onChange={(e) => setProfile({ ...profile, cwColor: e.target.value })}
|
||
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
|
||
/>
|
||
<select
|
||
value={isCustomFont(profile.cwFontFamily || "") || profile.cwFontFamily === "__custom__" ? "__custom__" : (profile.cwFontFamily || "Inter")}
|
||
onChange={(e) => setProfile({ ...profile, cwFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
{AVAILABLE_FONTS.map((font) => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{(isCustomFont(profile.cwFontFamily || "") || profile.cwFontFamily === "__custom__") && (
|
||
<input
|
||
type="text"
|
||
value={profile.cwFontFamily === "__custom__" ? "" : (profile.cwFontFamily || "")}
|
||
onChange={(e) => setProfile({ ...profile, cwFontFamily: e.target.value || "__custom__" })}
|
||
placeholder={t.fontPlaceholder}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
)}
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<input
|
||
type="text"
|
||
value={profile.cwFontSize || "1.125rem"}
|
||
onChange={(e) => setProfile({ ...profile, cwFontSize: e.target.value })}
|
||
placeholder="1.125rem"
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
<select
|
||
value={profile.cwFontWeight || "700"}
|
||
onChange={(e) => setProfile({ ...profile, cwFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
<option value="300">{t.weightLight}</option>
|
||
<option value="400">{t.weightNormal}</option>
|
||
<option value="500">{t.weightMedium}</option>
|
||
<option value="600">{t.weightSemi}</option>
|
||
<option value="700">{t.weightBold}</option>
|
||
<option value="900">{t.weightBlack}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Year Font */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
Year
|
||
</label>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||
<input
|
||
type="color"
|
||
value={profile.yearColor || "#333333"}
|
||
onChange={(e) => setProfile({ ...profile, yearColor: e.target.value })}
|
||
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
|
||
/>
|
||
<select
|
||
value={isCustomFont(profile.yearFontFamily || "") || profile.yearFontFamily === "__custom__" ? "__custom__" : (profile.yearFontFamily || "Inter")}
|
||
onChange={(e) => setProfile({ ...profile, yearFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
{AVAILABLE_FONTS.map((font) => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{(isCustomFont(profile.yearFontFamily || "") || profile.yearFontFamily === "__custom__") && (
|
||
<input
|
||
type="text"
|
||
value={profile.yearFontFamily === "__custom__" ? "" : (profile.yearFontFamily || "")}
|
||
onChange={(e) => setProfile({ ...profile, yearFontFamily: e.target.value || "__custom__" })}
|
||
placeholder={t.fontPlaceholder}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
)}
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<input
|
||
type="text"
|
||
value={profile.yearFontSize || "1.125rem"}
|
||
onChange={(e) => setProfile({ ...profile, yearFontSize: e.target.value })}
|
||
placeholder="1.125rem"
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
/>
|
||
<select
|
||
value={profile.yearFontWeight || "700"}
|
||
onChange={(e) => setProfile({ ...profile, yearFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||
>
|
||
<option value="300">{t.weightLight}</option>
|
||
<option value="400">{t.weightNormal}</option>
|
||
<option value="500">{t.weightMedium}</option>
|
||
<option value="600">{t.weightSemi}</option>
|
||
<option value="700">{t.weightBold}</option>
|
||
<option value="900">{t.weightBlack}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{/* Element Colors */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
Element Colors
|
||
</label>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "1fr 1fr",
|
||
gap: "12px",
|
||
}}
|
||
>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.75rem",
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
Today Highlight
|
||
</label>
|
||
<input
|
||
type="color"
|
||
value={profile.todayHighlightColor || "#f0fafa"}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
todayHighlightColor: e.target.value,
|
||
})
|
||
}
|
||
style={{
|
||
width: "100%",
|
||
height: "30px",
|
||
cursor: "pointer",
|
||
border: "none",
|
||
background: "transparent",
|
||
}}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.75rem",
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
Past Days
|
||
</label>
|
||
<input
|
||
type="color"
|
||
value={profile.pastDayColor || "#a6a6a7"}
|
||
onChange={(e) =>
|
||
setProfile({ ...profile, pastDayColor: e.target.value })
|
||
}
|
||
style={{
|
||
width: "100%",
|
||
height: "30px",
|
||
cursor: "pointer",
|
||
border: "none",
|
||
background: "transparent",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Weekend Colors */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
Weekend Highlight Colors
|
||
</label>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "1fr 1fr",
|
||
gap: "12px",
|
||
}}
|
||
>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.75rem",
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
Saturday
|
||
</label>
|
||
<input
|
||
type="color"
|
||
value={profile.weekendColorSat || "#666666"}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
weekendColorSat: e.target.value,
|
||
})
|
||
}
|
||
style={{
|
||
width: "100%",
|
||
height: "30px",
|
||
cursor: "pointer",
|
||
border: "none",
|
||
background: "transparent",
|
||
}}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.75rem",
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
Sunday
|
||
</label>
|
||
<input
|
||
type="color"
|
||
value={profile.weekendColorSun || "#dc2626"}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
weekendColorSun: e.target.value,
|
||
})
|
||
}
|
||
style={{
|
||
width: "100%",
|
||
height: "30px",
|
||
cursor: "pointer",
|
||
border: "none",
|
||
background: "transparent",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
marginTop: "auto",
|
||
display: "flex",
|
||
justifyContent: "flex-start",
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleUpdateProfile({ preventDefault: () => { } } as any)
|
||
}
|
||
className="weekly-btn-primary"
|
||
style={{ padding: "8px 16px" }}
|
||
>
|
||
{t.saveChanges}
|
||
</button>
|
||
{accountMsg && (
|
||
<span
|
||
style={{
|
||
marginLeft: "12px",
|
||
fontSize: "0.9rem",
|
||
color: accountMsg.includes("success")
|
||
? "#059669"
|
||
: "#dc2626",
|
||
alignSelf: "center",
|
||
}}
|
||
>
|
||
{accountMsg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : activeTab === "motivation" ? (
|
||
<div
|
||
style={{ display: "flex", flexDirection: "column", gap: "24px" }}
|
||
>
|
||
{/* Replaced Goal of the Week settings block */}
|
||
{/* "Do This Now" Toggle */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "8px",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
id="showNextTaskMotivation"
|
||
checked={showNextTask}
|
||
onChange={(e) => {
|
||
const newVal = e.target.checked;
|
||
setShowNextTask(newVal);
|
||
}}
|
||
style={{ width: "20px", height: "20px", cursor: "pointer" }}
|
||
/>
|
||
<label
|
||
htmlFor="showNextTaskMotivation"
|
||
style={{
|
||
fontSize: "1rem",
|
||
fontWeight: 500,
|
||
color: "var(--weekly-settings-title)",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
{t.showDoThisNow}
|
||
</label>
|
||
</div>
|
||
|
||
{/* Focus Timer Settings moved here */}
|
||
<div style={{ display: "flex", gap: "16px" }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
marginBottom: "8px",
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
{t.focusTimer}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="120"
|
||
value={profile.focusTimerDuration || 25}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
focusTimerDuration: parseInt(e.target.value) || 25,
|
||
})
|
||
}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "12px",
|
||
border: "1px solid var(--weekly-border)",
|
||
borderRadius: "6px",
|
||
fontSize: "1rem",
|
||
}}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
marginBottom: "8px",
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
{t.focusBreak}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="60"
|
||
value={profile.focusBreakDuration || 5}
|
||
onChange={(e) =>
|
||
setProfile({
|
||
...profile,
|
||
focusBreakDuration: parseInt(e.target.value) || 5,
|
||
})
|
||
}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "12px",
|
||
border: "1px solid var(--weekly-border)",
|
||
borderRadius: "6px",
|
||
fontSize: "1rem",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Goal Scope Redesign */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "20px",
|
||
borderRadius: "12px",
|
||
border: "1px solid var(--weekly-border)",
|
||
}}
|
||
>
|
||
<h3
|
||
style={{
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
marginBottom: "16px",
|
||
color: "var(--weekly-settings-title)",
|
||
}}
|
||
>
|
||
{t.goalScopeTitle}
|
||
</h3>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
background: "var(--weekly-bg)",
|
||
padding: "4px",
|
||
borderRadius: "8px",
|
||
border: "1px solid var(--weekly-border)",
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setProfile((p) => ({ ...p, goalScope: "week" }))
|
||
}
|
||
style={{
|
||
flex: 1,
|
||
padding: "10px",
|
||
borderRadius: "6px",
|
||
border: profile.goalScope === "week" ? "2px solid var(--weekly-teal)" : "none",
|
||
background:
|
||
profile.goalScope === "week"
|
||
? "var(--weekly-settings-toggle-active-bg)"
|
||
: "transparent",
|
||
color:
|
||
profile.goalScope === "week"
|
||
? "var(--weekly-settings-toggle-active-text)"
|
||
: "var(--weekly-settings-text)",
|
||
fontWeight: 700,
|
||
boxShadow: profile.goalScope === "week" ? "0 2px 4px rgba(0,0,154,0.1)" : "none",
|
||
cursor: "pointer",
|
||
transition: "all 0.2s",
|
||
}}
|
||
>
|
||
{t.goalScopeWeek}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setProfile((p) => ({ ...p, goalScope: "day" }))
|
||
}
|
||
style={{
|
||
flex: 1,
|
||
padding: "10px",
|
||
borderRadius: "6px",
|
||
border: profile.goalScope === "day" ? "2px solid var(--weekly-teal)" : "none",
|
||
background:
|
||
profile.goalScope === "day"
|
||
? "var(--weekly-settings-toggle-active-bg)"
|
||
: "transparent",
|
||
color:
|
||
profile.goalScope === "day"
|
||
? "var(--weekly-settings-toggle-active-text)"
|
||
: "var(--weekly-settings-text)",
|
||
fontWeight: 700,
|
||
boxShadow: profile.goalScope === "day" ? "0 2px 4px rgba(0,154,154,0.1)" : "none",
|
||
cursor: "pointer",
|
||
transition: "all 0.2s",
|
||
}}
|
||
>
|
||
{t.goalScopeDay}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Fallback Section */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "20px",
|
||
borderRadius: "12px",
|
||
border: "1px solid var(--weekly-border)",
|
||
}}
|
||
>
|
||
<h3
|
||
style={{
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
marginBottom: "8px",
|
||
color: "var(--weekly-settings-title)",
|
||
}}
|
||
>
|
||
{t.goalFallbackTitle}
|
||
</h3>
|
||
<div style={{ marginBottom: "12px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.85rem",
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
{t.goalFallback}
|
||
</label>
|
||
<select
|
||
value={profile.goalFallbackType || "quote"}
|
||
onChange={(e) =>
|
||
setProfile((p) => ({
|
||
...p,
|
||
goalFallbackType: e.target.value as any,
|
||
}))
|
||
}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "12px",
|
||
borderRadius: "6px",
|
||
border: "1px solid var(--weekly-border)",
|
||
background: "var(--weekly-bg)",
|
||
fontSize: "1rem",
|
||
}}
|
||
>
|
||
<option value="quote">
|
||
{t.motivationalQuote}
|
||
</option>
|
||
<option value="next_todo">{t.nextTodo}</option>
|
||
<option value="default">{t.defaultText}</option>
|
||
</select>
|
||
</div>
|
||
{(!profile.goalFallbackType || profile.goalFallbackType === "quote") && (
|
||
<div style={{ marginTop: "12px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
marginBottom: "4px",
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
{t.apiDataSources}
|
||
</label>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
{(profile.quoteSourceUrls || [profile.quoteSourceUrl || ""]).map((url: string, idx: number) => (
|
||
<div key={idx} style={{ display: "flex", gap: "8px" }}>
|
||
<input
|
||
type="text"
|
||
value={url}
|
||
onChange={(e) => {
|
||
const newUrls = [...(profile.quoteSourceUrls || [profile.quoteSourceUrl || ""])];
|
||
newUrls[idx] = e.target.value;
|
||
setProfile((p) => ({ ...p, quoteSourceUrls: newUrls }));
|
||
}}
|
||
className="weekly-input"
|
||
placeholder="https://..."
|
||
style={{
|
||
flex: 1,
|
||
padding: "10px",
|
||
fontSize: "0.95rem",
|
||
borderRadius: "6px",
|
||
border: "1px solid var(--weekly-border)",
|
||
background: "var(--weekly-bg)",
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const newUrls = (profile.quoteSourceUrls || [profile.quoteSourceUrl || ""]).filter((_val: string, i: number) => i !== idx);
|
||
setProfile((p) => ({ ...p, quoteSourceUrls: newUrls }));
|
||
}}
|
||
style={{
|
||
padding: "8px",
|
||
background: "#fee2e2",
|
||
color: "#ef4444",
|
||
border: "none",
|
||
borderRadius: "6px",
|
||
cursor: "pointer"
|
||
}}
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const newUrls = [...(profile.quoteSourceUrls || [profile.quoteSourceUrl || ""]), ""];
|
||
setProfile((p) => ({ ...p, quoteSourceUrls: newUrls }));
|
||
}}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
marginTop: "4px",
|
||
padding: "6px 12px",
|
||
fontSize: "0.85rem",
|
||
background: "var(--weekly-teal)",
|
||
color: "white",
|
||
border: "none",
|
||
borderRadius: "6px",
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "4px"
|
||
}}
|
||
>
|
||
<Plus size={14} /> {t.addSource}
|
||
</button>
|
||
</div>
|
||
<p style={{ fontSize: "0.75rem", color: "var(--weekly-text-light)", marginTop: "4px" }}>
|
||
{t.urlFormatHelp}
|
||
</p>
|
||
<p style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", marginTop: "8px", fontStyle: "italic" }}>
|
||
{t.quoteFallbackDesc}
|
||
</p>
|
||
<div style={{ marginTop: "12px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
marginBottom: "6px",
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
{t.quoteLanguages}
|
||
</label>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
|
||
{[
|
||
{ code: "en", label: "English" },
|
||
{ code: "de", label: "Deutsch" },
|
||
{ code: "fr", label: "Français" },
|
||
{ code: "es", label: "Español" },
|
||
{ code: "it", label: "Italiano" },
|
||
].map((lang) => {
|
||
const selected = (profile.quoteLanguages || ["en", "de"]).includes(lang.code);
|
||
return (
|
||
<label
|
||
key={lang.code}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
fontSize: "0.85rem",
|
||
cursor: "pointer",
|
||
padding: "4px 10px",
|
||
borderRadius: "6px",
|
||
border: selected ? "1px solid var(--weekly-teal)" : "1px solid var(--weekly-border)",
|
||
background: selected ? "var(--weekly-teal)" : "transparent",
|
||
color: selected ? "white" : "var(--weekly-text)",
|
||
transition: "all 0.15s ease",
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selected}
|
||
onChange={() => {
|
||
const current = profile.quoteLanguages || ["en", "de"];
|
||
const updated = selected
|
||
? current.filter((c: string) => c !== lang.code)
|
||
: [...current, lang.code];
|
||
if (updated.length > 0) {
|
||
setProfile((p) => ({ ...p, quoteLanguages: updated }));
|
||
}
|
||
}}
|
||
style={{ display: "none" }}
|
||
/>
|
||
{lang.label}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
<p style={{ fontSize: "0.75rem", color: "var(--weekly-text-light)", marginTop: "4px" }}>
|
||
{t.quoteLanguagesDesc}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{profile.goalFallbackType === "default" && (
|
||
<div style={{ marginTop: "12px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
marginBottom: "4px",
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
{t.defaultText}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={profile.goalDefaultSentence || ""}
|
||
onChange={(e) =>
|
||
setProfile((p) => ({
|
||
...p,
|
||
goalDefaultSentence: e.target.value,
|
||
}))
|
||
}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "12px",
|
||
borderRadius: "6px",
|
||
border: "1px solid var(--weekly-border)",
|
||
}}
|
||
placeholder={t.defaultGoalPlaceholder}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div
|
||
style={{
|
||
marginTop: "auto",
|
||
paddingTop: "24px",
|
||
display: "flex",
|
||
justifyContent: "flex-start",
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleUpdateProfile({ preventDefault: () => { } } as any)
|
||
}
|
||
className="weekly-btn-primary"
|
||
style={{
|
||
padding: "12px 24px",
|
||
fontWeight: 600,
|
||
fontSize: "1rem",
|
||
}}
|
||
>
|
||
{t.saveChanges}
|
||
</button>
|
||
{accountMsg && (
|
||
<span
|
||
style={{
|
||
marginLeft: "12px",
|
||
fontSize: "0.9rem",
|
||
color: accountMsg.includes("success")
|
||
? "#059669"
|
||
: "#dc2626",
|
||
alignSelf: "center",
|
||
}}
|
||
>
|
||
{accountMsg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : activeTab === "about" ? (
|
||
<div
|
||
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
|
||
>
|
||
<div style={{ textAlign: "center", marginBottom: "20px" }}>
|
||
<h3
|
||
style={{
|
||
fontSize: "1.2rem",
|
||
fontWeight: 700,
|
||
marginBottom: "8px",
|
||
color: "var(--weekly-settings-title)",
|
||
}}
|
||
>
|
||
My Weekly To-Do List
|
||
</h3>
|
||
<p
|
||
style={{
|
||
fontSize: "0.9rem",
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
Version {process.env.NEXT_PUBLIC_APP_VERSION || "1.8.0"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* 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>
|
||
{profile.id && (
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
{t.accountId}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={profile.id}
|
||
readOnly
|
||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
border: "1px solid #eee",
|
||
borderRadius: "4px",
|
||
background: "#f5f5f5",
|
||
color: "#555",
|
||
fontSize: "0.85rem",
|
||
fontFamily: "monospace",
|
||
cursor: "text",
|
||
}}
|
||
/>
|
||
<span style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", opacity: 0.7 }}>
|
||
{t.accountIdDesc}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{profile.accountNumber && (
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
{t.accountNumberLabel}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={`#${profile.accountNumber}`}
|
||
readOnly
|
||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px",
|
||
border: "1px solid #eee",
|
||
borderRadius: "4px",
|
||
background: "#f5f5f5",
|
||
color: "#555",
|
||
fontSize: "0.85rem",
|
||
fontFamily: "monospace",
|
||
cursor: "text",
|
||
}}
|
||
/>
|
||
<span style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", opacity: 0.7 }}>
|
||
{t.accountNumberDesc}
|
||
</span>
|
||
</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>
|
||
|
||
<div
|
||
style={{
|
||
marginTop: "16px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "12px",
|
||
}}
|
||
>
|
||
<button
|
||
type="submit"
|
||
className="weekly-btn-primary"
|
||
style={{ padding: "10px 20px" }}
|
||
>
|
||
{t.saveChanges}
|
||
</button>
|
||
{accountMsg && (
|
||
<span
|
||
style={{
|
||
fontSize: "0.9rem",
|
||
color: accountMsg.toLowerCase().includes("success")
|
||
? "#059669"
|
||
: "#dc2626",
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{accountMsg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</form>
|
||
|
||
{/* Data Export Section */}
|
||
<div
|
||
style={{
|
||
marginTop: "20px",
|
||
paddingTop: "20px",
|
||
borderTop: "1px solid var(--weekly-border)",
|
||
}}
|
||
>
|
||
<h4
|
||
style={{
|
||
marginBottom: "10px",
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-title)",
|
||
}}
|
||
>
|
||
{profile.language === "de" ? "Datenexport" : "Data Export"}
|
||
</h4>
|
||
<p
|
||
style={{
|
||
fontSize: "0.9rem",
|
||
color: "var(--weekly-settings-label)",
|
||
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",
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
Start
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={exportStartDate}
|
||
onChange={(e) => setExportStartDate(e.target.value)}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "6px",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
}}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.8rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
End
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={exportEndDate}
|
||
onChange={(e) => setExportEndDate(e.target.value)}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "6px",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<a
|
||
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}`}
|
||
target="_blank"
|
||
className="weekly-auth-button w-full justify-center"
|
||
style={{
|
||
display: "inline-flex",
|
||
textDecoration: "none",
|
||
background: "var(--weekly-settings-item-bg)",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
color: "var(--weekly-settings-text)",
|
||
padding: "10px",
|
||
borderRadius: "4px",
|
||
fontWeight: 500,
|
||
transition: "background-color 0.2s",
|
||
}}
|
||
>
|
||
{profile.language === "de"
|
||
? "Erledigte Aufgaben exportieren (CSV)"
|
||
: "Export Completed Tasks (CSV)"}
|
||
</a>
|
||
</div>
|
||
|
||
{/* Backup & Restore Section */}
|
||
<div
|
||
style={{
|
||
marginTop: "20px",
|
||
paddingTop: "20px",
|
||
borderTop: "1px solid var(--weekly-border)",
|
||
}}
|
||
>
|
||
<h4
|
||
style={{
|
||
marginBottom: "10px",
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-title)",
|
||
}}
|
||
>
|
||
{t.backupRestore}
|
||
</h4>
|
||
<p
|
||
style={{
|
||
fontSize: "0.9rem",
|
||
color: "var(--weekly-settings-label)",
|
||
marginBottom: "15px",
|
||
}}
|
||
>
|
||
{t.backupRestoreDesc}
|
||
</p>
|
||
|
||
{/* Export All Data */}
|
||
<button
|
||
onClick={handleExportAllData}
|
||
disabled={isExportingAll}
|
||
className="weekly-auth-button w-full justify-center"
|
||
style={{
|
||
display: "inline-flex",
|
||
width: "100%",
|
||
padding: "10px",
|
||
background: "var(--weekly-settings-item-bg)",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
color: "var(--weekly-settings-text)",
|
||
borderRadius: "4px",
|
||
fontWeight: 500,
|
||
cursor: isExportingAll ? "wait" : "pointer",
|
||
transition: "background-color 0.2s",
|
||
marginBottom: "15px",
|
||
opacity: isExportingAll ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{isExportingAll ? t.exporting : t.exportAllData}
|
||
</button>
|
||
|
||
{/* Import Section */}
|
||
<div
|
||
style={{
|
||
padding: "12px",
|
||
border: "1px solid var(--weekly-border)",
|
||
borderRadius: "6px",
|
||
background: "var(--weekly-settings-item-bg)",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "10px",
|
||
color: "var(--weekly-settings-text)",
|
||
}}
|
||
>
|
||
{t.importData}
|
||
</label>
|
||
|
||
{/* Import Mode Toggle */}
|
||
<div style={{ marginBottom: "10px" }}>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.8rem",
|
||
fontWeight: 600,
|
||
marginBottom: "6px",
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
{t.importMode}
|
||
</label>
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<button
|
||
onClick={() => setImportMode("merge")}
|
||
style={{
|
||
flex: 1,
|
||
padding: "8px",
|
||
borderRadius: "4px",
|
||
border: importMode === "merge"
|
||
? "2px solid var(--weekly-teal)"
|
||
: "1px solid var(--weekly-settings-input-border)",
|
||
background: importMode === "merge"
|
||
? "rgba(20, 184, 166, 0.1)"
|
||
: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
cursor: "pointer",
|
||
fontSize: "0.85rem",
|
||
fontWeight: importMode === "merge" ? 600 : 400,
|
||
transition: "all 0.2s",
|
||
}}
|
||
>
|
||
<div>{t.importModeMerge}</div>
|
||
<div style={{ fontSize: "0.75rem", opacity: 0.7, marginTop: "2px" }}>
|
||
{t.importModeMergeDesc}
|
||
</div>
|
||
</button>
|
||
<button
|
||
onClick={() => setImportMode("replace")}
|
||
style={{
|
||
flex: 1,
|
||
padding: "8px",
|
||
borderRadius: "4px",
|
||
border: importMode === "replace"
|
||
? "2px solid #ef4444"
|
||
: "1px solid var(--weekly-settings-input-border)",
|
||
background: importMode === "replace"
|
||
? "rgba(239, 68, 68, 0.1)"
|
||
: "var(--weekly-settings-input-bg)",
|
||
color: importMode === "replace" ? "#ef4444" : "var(--weekly-settings-text)",
|
||
cursor: "pointer",
|
||
fontSize: "0.85rem",
|
||
fontWeight: importMode === "replace" ? 600 : 400,
|
||
transition: "all 0.2s",
|
||
}}
|
||
>
|
||
<div>{t.importModeReplace}</div>
|
||
<div style={{ fontSize: "0.75rem", opacity: 0.7, marginTop: "2px" }}>
|
||
{t.importModeReplaceDesc}
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{importMode === "replace" && (
|
||
<div
|
||
style={{
|
||
padding: "8px 10px",
|
||
marginBottom: "10px",
|
||
borderRadius: "4px",
|
||
background: "rgba(239, 68, 68, 0.08)",
|
||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||
fontSize: "0.8rem",
|
||
color: "#ef4444",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{t.importReplaceWarning}
|
||
</div>
|
||
)}
|
||
|
||
{/* File Input */}
|
||
<input
|
||
id="import-file-input"
|
||
type="file"
|
||
accept=".json"
|
||
onChange={(e) => {
|
||
setImportFile(e.target.files?.[0] || null);
|
||
setImportMsg("");
|
||
}}
|
||
className="weekly-input"
|
||
style={{
|
||
width: "100%",
|
||
padding: "6px",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
marginBottom: "10px",
|
||
fontSize: "0.85rem",
|
||
}}
|
||
/>
|
||
|
||
<button
|
||
onClick={handleImportData}
|
||
disabled={!importFile || isImporting}
|
||
className="weekly-auth-button w-full justify-center"
|
||
style={{
|
||
display: "inline-flex",
|
||
width: "100%",
|
||
padding: "10px",
|
||
background: !importFile || isImporting
|
||
? "var(--weekly-settings-input-bg)"
|
||
: "var(--weekly-teal)",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
color: !importFile || isImporting
|
||
? "var(--weekly-settings-label)"
|
||
: "#fff",
|
||
borderRadius: "4px",
|
||
fontWeight: 600,
|
||
cursor: !importFile || isImporting ? "not-allowed" : "pointer",
|
||
transition: "background-color 0.2s",
|
||
opacity: !importFile || isImporting ? 0.6 : 1,
|
||
}}
|
||
>
|
||
{isImporting ? t.importing : t.importButton}
|
||
</button>
|
||
|
||
{importMsg && (
|
||
<p
|
||
style={{
|
||
marginTop: "10px",
|
||
fontSize: "0.85rem",
|
||
fontWeight: 600,
|
||
color: importMsg.startsWith("✓")
|
||
? "#059669"
|
||
: "#dc2626",
|
||
}}
|
||
>
|
||
{importMsg}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className="account-danger-zone"
|
||
style={{
|
||
marginTop: "20px",
|
||
paddingTop: "20px",
|
||
borderTop: "1px solid var(--weekly-border)",
|
||
}}
|
||
>
|
||
{/* Sign Out Button - accessible on mobile */}
|
||
<button
|
||
onClick={() => signOut()}
|
||
className="weekly-auth-button w-full justify-center"
|
||
style={{
|
||
marginBottom: "18px",
|
||
padding: "10px",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
background: "var(--weekly-settings-item-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
fontWeight: 500,
|
||
transition: "background-color 0.2s",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "8px",
|
||
width: "100%",
|
||
}}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||
<polyline points="16 17 21 12 16 7"></polyline>
|
||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||
</svg>
|
||
{t.signOut}
|
||
</button>
|
||
|
||
<h3
|
||
style={{
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
marginBottom: "10px",
|
||
color: "var(--weekly-settings-title)",
|
||
}}
|
||
>
|
||
{t.dataPrivacy}
|
||
</h3>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "10px",
|
||
}}
|
||
>
|
||
<button
|
||
onClick={handleDownloadData}
|
||
className="weekly-auth-button w-full justify-center"
|
||
style={{
|
||
padding: "10px",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
background: "var(--weekly-settings-item-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
fontWeight: 500,
|
||
transition: "background-color 0.2s",
|
||
}}
|
||
>
|
||
{t.downloadData}
|
||
</button>
|
||
<button
|
||
onClick={handleDeleteAccount}
|
||
className="weekly-auth-button w-full justify-center"
|
||
style={{
|
||
padding: "10px",
|
||
border: "1px solid #ef4444",
|
||
background: "rgba(239, 68, 68, 0.05)",
|
||
color: "#ef4444",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
fontWeight: 600,
|
||
transition: "background-color 0.2s",
|
||
}}
|
||
onMouseOver={(e) =>
|
||
(e.currentTarget.style.background =
|
||
"rgba(239, 68, 68, 0.1)")
|
||
}
|
||
onMouseOut={(e) =>
|
||
(e.currentTarget.style.background =
|
||
"rgba(239, 68, 68, 0.05)")
|
||
}
|
||
>
|
||
{t.deleteAccount}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Apple Calendar (CalDAV) Connection Modal */}
|
||
{showAppleCalendarModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[2000]">
|
||
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
|
||
<h3 className="text-xl font-bold mb-4">
|
||
Connect Apple Calendar
|
||
</h3>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
|
||
<p style={{ marginBottom: "6px" }}>
|
||
Connect your iCloud Calendar events via CalDAV.
|
||
</p>
|
||
<p style={{ fontSize: "0.8rem", opacity: 0.85 }}>
|
||
This requires an{" "}
|
||
<a
|
||
href="https://support.apple.com/en-us/102654"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ textDecoration: "underline" }}
|
||
>
|
||
app-specific password
|
||
</a>{" "}
|
||
generated at appleid.apple.com.
|
||
</p>
|
||
</div>
|
||
|
||
{appleCalError && (
|
||
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
||
{appleCalError}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Apple ID (Email)
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={appleCalEmail}
|
||
onChange={(e) => setAppleCalEmail(e.target.value)}
|
||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||
placeholder="name@icloud.com"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
App-Specific Password
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={appleCalPassword}
|
||
onChange={(e) => setAppleCalPassword(e.target.value)}
|
||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||
placeholder="xxxx-xxxx-xxxx-xxxx"
|
||
onKeyDown={(e) =>
|
||
e.key === "Enter" && submitAppleCalendarConnection()
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 mt-6">
|
||
<button
|
||
onClick={() => {
|
||
setShowAppleCalendarModal(false);
|
||
setAppleCalError("");
|
||
}}
|
||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||
disabled={isConnectingAppleCal}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={submitAppleCalendarConnection}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
|
||
disabled={isConnectingAppleCal}
|
||
>
|
||
{isConnectingAppleCal ? (
|
||
<>
|
||
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
|
||
Connecting...
|
||
</>
|
||
) : (
|
||
"Connect"
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Synology Calendar Connection Modal */}
|
||
{showSynologyCalendarModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[2000]">
|
||
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
|
||
<h3 className="text-xl font-bold mb-4">
|
||
Connect Synology Calendar
|
||
</h3>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
|
||
<p style={{ marginBottom: "6px" }}>
|
||
Connect your Synology NAS Calendar events.
|
||
</p>
|
||
<p style={{ fontSize: "0.8rem", opacity: 0.85 }}>
|
||
Make sure Synology Calendar is installed and the CalDAV URL is reachable over HTTPS.
|
||
</p>
|
||
</div>
|
||
|
||
{synologyCalError && (
|
||
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
||
{synologyCalError}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Server URL (CalDAV)
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={synologyCalServerUrl}
|
||
onChange={(e) => setSynologyCalServerUrl(e.target.value)}
|
||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||
placeholder="https://your-synology-nas:5001"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Username
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={synologyCalUsername}
|
||
onChange={(e) => setSynologyCalUsername(e.target.value)}
|
||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||
placeholder="admin"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Password
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={synologyCalPassword}
|
||
onChange={(e) => setSynologyCalPassword(e.target.value)}
|
||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||
onKeyDown={(e) =>
|
||
e.key === "Enter" && submitSynologyCalendarConnection()
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 mt-6">
|
||
<button
|
||
onClick={() => {
|
||
setShowSynologyCalendarModal(false);
|
||
setSynologyCalError("");
|
||
}}
|
||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||
disabled={isConnectingSynologyCal}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={submitSynologyCalendarConnection}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
|
||
disabled={isConnectingSynologyCal}
|
||
>
|
||
{isConnectingSynologyCal ? (
|
||
<>
|
||
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
|
||
Connecting...
|
||
</>
|
||
) : (
|
||
"Connect"
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div >
|
||
</>
|
||
);
|
||
}
|