Adds a "CSS Variables" panel to the Styling tab with a color picker and description for every color token in globals.css (base, sidebar, design token aliases, event colors, all-day chips), plus a "Custom CSS" textarea that loads after the default stylesheet and can override any rule. Overrides are stored per-user (customCssVars, customCss) and applied on the root container's inline style, so they take precedence over themes and per-field settings. v1.113.0
5581 lines
359 KiB
TypeScript
5581 lines
359 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||
import { signOut } from "next-auth/react";
|
||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||
import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
|
||
import { faServer, faFolder } from "@fortawesome/free-solid-svg-icons";
|
||
import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel";
|
||
import { ViewStyle, KanbanStage, Task } from "./WeeklyView";
|
||
import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants";
|
||
import { translations } from "../lib/weeklyViewTranslations";
|
||
import { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewConstants";
|
||
import { EXPORT_FIELDS, type ExportFieldKey } from "../app/api/user/export/route";
|
||
import {
|
||
ArrowLeftRight,
|
||
Briefcase,
|
||
Calendar,
|
||
CalendarDays,
|
||
Check,
|
||
FolderOpen,
|
||
Globe,
|
||
Info,
|
||
Kanban,
|
||
Link,
|
||
ListTodo,
|
||
Palette,
|
||
Pencil,
|
||
Play,
|
||
Plus,
|
||
Settings,
|
||
Sparkles,
|
||
Trash2,
|
||
User,
|
||
} from "lucide-react";
|
||
import IconPicker from "./IconPicker";
|
||
import { allIcons } from "./iconRegistry";
|
||
import Icon from "@mdi/react";
|
||
import FlagIcon from "./FlagIcon";
|
||
import SearchableDropdown from "./SearchableDropdown";
|
||
import { TIMEZONE_OPTIONS, formatTimezone, getOffsetMinutes, formatOffset } from "../lib/timezones";
|
||
|
||
// Punkt 6 — manage a user's extra timezones (for cross-team scheduling).
|
||
// Stored in user.viewSettings.extraTimezones to avoid an extra migration.
|
||
function ExtraTimezonesEditor({
|
||
profile,
|
||
setProfile,
|
||
saveSetting,
|
||
}: {
|
||
profile: any;
|
||
setProfile: React.Dispatch<React.SetStateAction<any>>;
|
||
saveSetting: (key: string, value: any) => void;
|
||
}) {
|
||
const de = profile.language === "de";
|
||
const extras: string[] = (profile.viewSettings && profile.viewSettings.extraTimezones) || [];
|
||
const [picker, setPicker] = useState("");
|
||
|
||
const persist = (next: string[]) => {
|
||
const newViewSettings = { ...(profile.viewSettings || {}), extraTimezones: next };
|
||
setProfile((p: any) => ({ ...p, viewSettings: newViewSettings }));
|
||
saveSetting("viewSettings", newViewSettings);
|
||
};
|
||
const add = (zone: string) => {
|
||
if (!zone || extras.includes(zone) || zone === profile.timezone) return;
|
||
persist([...extras, zone]);
|
||
setPicker("");
|
||
};
|
||
const remove = (zone: string) => {
|
||
persist(extras.filter((z) => z !== zone));
|
||
};
|
||
|
||
return (
|
||
<div style={{ marginTop: "10px" }}>
|
||
<label style={{ display: "block", fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", marginBottom: "4px" }}>
|
||
{de ? "Zusätzliche Zeitzonen" : "Additional Timezones"}
|
||
</label>
|
||
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 6px", opacity: 0.8 }}>
|
||
{de
|
||
? "Hilfreich, wenn du mit Teammitgliedern in anderen Zeitzonen arbeitest. Zeiten werden im Tageskopf angezeigt."
|
||
: "Useful when collaborating with people in other timezones. Times are shown in the day header."}
|
||
</p>
|
||
{extras.length > 0 && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "4px", marginBottom: "6px" }}>
|
||
{extras.map((z) => {
|
||
const opt = TIMEZONE_OPTIONS.find((o) => o.zone === z);
|
||
return (
|
||
<div key={z} style={{
|
||
display: "flex", alignItems: "center", gap: "8px",
|
||
padding: "4px 8px", borderRadius: "6px",
|
||
background: "var(--weekly-bg-soft, #f9fafb)",
|
||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||
}}>
|
||
<span style={{ fontSize: "0.8rem", flex: 1 }}>
|
||
{opt ? formatTimezone(opt) : z}
|
||
</span>
|
||
<button
|
||
onClick={() => remove(z)}
|
||
title={de ? "Entfernen" : "Remove"}
|
||
style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
<div style={{ display: "flex", gap: "6px" }}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<SearchableDropdown
|
||
value={picker}
|
||
onChange={(v) => setPicker(v)}
|
||
searchable
|
||
placeholder={de ? "Zeitzone auswählen…" : "Select a timezone…"}
|
||
searchPlaceholder={de ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
|
||
emptyText={de ? "Keine Treffer" : "No matches"}
|
||
options={TIMEZONE_OPTIONS
|
||
.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone)
|
||
.sort((a, b) => {
|
||
const oa = getOffsetMinutes(a.zone);
|
||
const ob = getOffsetMinutes(b.zone);
|
||
if (oa !== ob) return oa - ob;
|
||
return a.code.localeCompare(b.code);
|
||
})
|
||
.map((opt) => {
|
||
const off = formatOffset(getOffsetMinutes(opt.zone));
|
||
return {
|
||
value: opt.zone,
|
||
label: `${opt.code} — ${opt.label}`,
|
||
secondary: `UTC${off}`,
|
||
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
|
||
};
|
||
})}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => add(picker)}
|
||
disabled={!picker}
|
||
className="weekly-btn-primary"
|
||
style={{ padding: "6px 10px", fontSize: "0.85rem", display: "inline-flex", alignItems: "center", gap: "4px", opacity: picker ? 1 : 0.5 }}
|
||
>
|
||
<Plus size={14} /> {de ? "Hinzufügen" : "Add"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// All customizable color custom-properties defined in globals.css :root, grouped for the
|
||
// "Styling" tab's CSS Variables editor. Overrides are stored as a flat { "--var-name": "#hex" }
|
||
// map in profile.customCssVars and applied on top of containerStyle in WeeklyView.
|
||
const CSS_VARIABLE_GROUPS: { title: string; vars: { name: string; label: string; default: string }[] }[] = [
|
||
{
|
||
title: "Base Colors",
|
||
vars: [
|
||
{ name: "--weekly-teal", label: "Accent", default: "#009a9a" },
|
||
{ name: "--weekly-bg", label: "Background", default: "#ffffff" },
|
||
{ name: "--weekly-text", label: "Text", default: "#000000" },
|
||
{ name: "--weekly-text-light", label: "Text (light)", default: "#767676" },
|
||
{ name: "--weekly-border", label: "Border", default: "#cccccc" },
|
||
{ name: "--weekly-completed", label: "Completed task", default: "#cccccc" },
|
||
],
|
||
},
|
||
{
|
||
title: "Settings Sidebar",
|
||
vars: [
|
||
{ name: "--weekly-settings-bg", label: "Background", default: "#ffffff" },
|
||
{ name: "--weekly-settings-item-bg", label: "Item background", default: "#f8fafc" },
|
||
{ name: "--weekly-settings-label", label: "Label", default: "#475569" },
|
||
{ name: "--weekly-settings-title", label: "Title", default: "#111827" },
|
||
{ name: "--weekly-settings-input-bg", label: "Input background", default: "#ffffff" },
|
||
{ name: "--weekly-settings-input-border", label: "Input border", default: "#dddddd" },
|
||
{ name: "--weekly-settings-text", label: "Text", default: "#111827" },
|
||
{ name: "--weekly-settings-toggle-bg", label: "Toggle background", default: "#f3f4f6" },
|
||
{ name: "--weekly-settings-toggle-active-bg", label: "Toggle active background", default: "#ffffff" },
|
||
{ name: "--weekly-settings-toggle-text", label: "Toggle text", default: "#6b7280" },
|
||
{ name: "--weekly-settings-toggle-active-text", label: "Toggle active text", default: "#111827" },
|
||
],
|
||
},
|
||
{
|
||
title: "Design Token Aliases",
|
||
vars: [
|
||
{ name: "--bg", label: "Background (alias)", default: "#ffffff" },
|
||
{ name: "--paper", label: "Paper (alias)", default: "#ffffff" },
|
||
{ name: "--ink", label: "Text (alias)", default: "#000000" },
|
||
{ name: "--ink-2", label: "Text variant 2", default: "#2a2a28" },
|
||
{ name: "--ink-3", label: "Text light (alias)", default: "#767676" },
|
||
{ name: "--ink-4", label: "Text variant 4", default: "#a8a8a2" },
|
||
{ name: "--ink-5", label: "Text variant 5", default: "#c8c8c2" },
|
||
{ name: "--accent", label: "Accent (alias)", default: "#009a9a" },
|
||
{ name: "--line", label: "Border (alias)", default: "#cccccc" },
|
||
{ name: "--line-soft", label: "Border (soft)", default: "#f3f3f0" },
|
||
{ name: "--weekend", label: "Weekend", default: "#dc2626" },
|
||
{ name: "--tasks-bg", label: "Tasks background", default: "#f4f2ee" },
|
||
],
|
||
},
|
||
{
|
||
title: "Event Colors — Default",
|
||
vars: [
|
||
{ name: "--ev-default-bg", label: "Background", default: "#eef2f7" },
|
||
{ name: "--ev-default-border", label: "Border", default: "#6c87a8" },
|
||
{ name: "--ev-default-title", label: "Title", default: "#2b3f57" },
|
||
{ name: "--ev-default-meta", label: "Meta", default: "#6c87a8" },
|
||
],
|
||
},
|
||
{
|
||
title: "Event Colors — Family",
|
||
vars: [
|
||
{ name: "--ev-fam-bg", label: "Background", default: "#fbeef0" },
|
||
{ name: "--ev-fam-border", label: "Border", default: "#b85a6a" },
|
||
{ name: "--ev-fam-title", label: "Title", default: "#5a2530" },
|
||
{ name: "--ev-fam-meta", label: "Meta", default: "#99536a" },
|
||
],
|
||
},
|
||
{
|
||
title: "Event Colors — Finance",
|
||
vars: [
|
||
{ name: "--ev-fin-bg", label: "Background", default: "#f6f0e2" },
|
||
{ name: "--ev-fin-border", label: "Border", default: "#a88a3c" },
|
||
{ name: "--ev-fin-title", label: "Title", default: "#4a3a14" },
|
||
{ name: "--ev-fin-meta", label: "Meta", default: "#8a6f2a" },
|
||
],
|
||
},
|
||
{
|
||
title: "Event Colors — Development",
|
||
vars: [
|
||
{ name: "--ev-dev-bg", label: "Background", default: "#ecf3ed" },
|
||
{ name: "--ev-dev-border", label: "Border", default: "#5a7a4a" },
|
||
{ name: "--ev-dev-title", label: "Title", default: "#2c4527" },
|
||
{ name: "--ev-dev-meta", label: "Meta", default: "#5a7a4a" },
|
||
],
|
||
},
|
||
{
|
||
title: "All-Day Chips",
|
||
vars: [
|
||
{ name: "--chip-default-bg", label: "Default", default: "#b8b8b0" },
|
||
{ name: "--chip-fam-bg", label: "Family", default: "#e88a8a" },
|
||
{ name: "--chip-special-bg", label: "Special background", default: "#f4d588" },
|
||
{ name: "--chip-special-text", label: "Special text", default: "#6b4f10" },
|
||
],
|
||
},
|
||
];
|
||
|
||
// Lets the user override any globals.css :root color variable individually. Overrides are
|
||
// merged into profile.customCssVars (a flat map) and spread onto the root container's inline
|
||
// style in WeeklyView, so they take precedence over every other color source (theme, per-field
|
||
// settings, etc.).
|
||
function CssVariablesEditor({
|
||
profile,
|
||
setProfile,
|
||
saveSetting,
|
||
}: {
|
||
profile: any;
|
||
setProfile: React.Dispatch<React.SetStateAction<any>>;
|
||
saveSetting: (key: string, value: any) => void;
|
||
}) {
|
||
const de = profile.language === "de";
|
||
const vars: Record<string, string> = profile.customCssVars || {};
|
||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
const setVar = (name: string, value: string) => {
|
||
const next = { ...(profile.customCssVars || {}), [name]: value };
|
||
setProfile((p: any) => ({ ...p, customCssVars: next }));
|
||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||
debounceTimer.current = setTimeout(() => saveSetting("customCssVars", next), 500);
|
||
};
|
||
|
||
const reset = () => {
|
||
setProfile((p: any) => ({ ...p, customCssVars: {} }));
|
||
saveSetting("customCssVars", {});
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" }}>
|
||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
||
{de ? "CSS-Variablen" : "CSS Variables"}
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={reset}
|
||
style={{
|
||
fontSize: "0.7rem",
|
||
background: "none",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "6px",
|
||
padding: "3px 8px",
|
||
cursor: "pointer",
|
||
color: "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
{de ? "Zurücksetzen" : "Reset"}
|
||
</button>
|
||
</div>
|
||
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 10px", opacity: 0.8 }}>
|
||
{de
|
||
? "Passe jede Design-Farbvariable des Stylesheets einzeln an."
|
||
: "Fine-tune every design-token color used by the stylesheet."}
|
||
</p>
|
||
{CSS_VARIABLE_GROUPS.map((group) => (
|
||
<div key={group.title} style={{ marginBottom: "12px" }}>
|
||
<div
|
||
style={{
|
||
fontSize: "0.7rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-title)",
|
||
marginBottom: "6px",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.02em",
|
||
}}
|
||
>
|
||
{group.title}
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px 12px" }}>
|
||
{group.vars.map((v) => (
|
||
<div
|
||
key={v.name}
|
||
title={v.name}
|
||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: "0.72rem",
|
||
color: "var(--weekly-settings-label)",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{v.label}
|
||
</span>
|
||
<input
|
||
type="color"
|
||
value={vars[v.name] || v.default}
|
||
onChange={(e) => setVar(v.name, e.target.value)}
|
||
style={{
|
||
width: "32px",
|
||
height: "22px",
|
||
cursor: "pointer",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "transparent",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Minimal ProjectIcon — resolves an icon name from the unified registry.
|
||
function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) {
|
||
if (!icon) return <FontAwesomeIcon icon={faFolder} style={{ fontSize: size, color }} />;
|
||
const normalised = icon.startsWith("fa") && icon.length > 2 && icon[2] === icon[2].toUpperCase()
|
||
? icon.slice(2, 3).toLowerCase() + icon.slice(3)
|
||
: icon;
|
||
const found = allIcons.find((i) => i.name === normalised || i.name === icon);
|
||
if (found) {
|
||
if (found.type === "fa") {
|
||
return <FontAwesomeIcon icon={found.icon as any} style={{ fontSize: size, color }} />;
|
||
}
|
||
return <Icon path={found.icon as string} size={size / 24} color={color} />;
|
||
}
|
||
return <span style={{ fontSize: size, lineHeight: 1 }}>{icon}</span>;
|
||
}
|
||
|
||
export interface SomedayList {
|
||
id: string;
|
||
title: string;
|
||
tab?: string | null;
|
||
tasks: Task[];
|
||
externalId?: string | null;
|
||
externalProvider?: string | null;
|
||
externalListId?: string | null;
|
||
}
|
||
|
||
export type CellDuration = 15 | 20 | 30 | 60;
|
||
|
||
// 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;
|
||
profile: any;
|
||
setProfile: React.Dispatch<React.SetStateAction<any>>;
|
||
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" | "sync" | "projects";
|
||
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||
onProjectsChanged: () => void;
|
||
kanbanStages: KanbanStage[];
|
||
saveKanbanStages: (stages: KanbanStage[]) => Promise<void>;
|
||
// Quick actions
|
||
isMobile?: boolean;
|
||
mobileActions?: {
|
||
goToPrevWeek: () => void;
|
||
goToPrevDay: () => void;
|
||
goToToday: () => void;
|
||
goToNextDay: () => void;
|
||
goToNextWeek: () => void;
|
||
onJumpToDate: () => void;
|
||
onAddCalendarEvent: () => void;
|
||
onAddProject: () => void;
|
||
onRecurringTasks: () => void;
|
||
onToggleNextTask: () => void;
|
||
onFocusMode: () => void;
|
||
onToggleDarkMode: () => void;
|
||
onSearch: () => void;
|
||
onUndo: () => void;
|
||
onRedo: () => void;
|
||
onRefresh: () => void;
|
||
darkMode: boolean;
|
||
showNextTask: boolean;
|
||
undoCount: number;
|
||
redoCount: number;
|
||
viewDays: number;
|
||
onViewDaysChange: (days: number) => void;
|
||
showTimeGrid: boolean;
|
||
cellDuration: CellDuration;
|
||
onCellDurationChange: (d: CellDuration) => void;
|
||
viewStyle: string;
|
||
onViewStyleChange: (style: string) => void;
|
||
startHour: number;
|
||
endHour: number;
|
||
onStartHourChange: (h: number) => void;
|
||
onEndHourChange: (h: number) => void;
|
||
};
|
||
perView: {
|
||
saveViewSetting: (key: string, value: any, perView: boolean) => void;
|
||
getEffective: (key: string, globalVal: any) => any;
|
||
};
|
||
onRunSetupAssistant?: () => void;
|
||
}
|
||
|
||
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,
|
||
profile,
|
||
setProfile,
|
||
isMobile: isMobileSidebar,
|
||
mobileActions,
|
||
perView,
|
||
onRunSetupAssistant,
|
||
}: SettingsSidebarProps) {
|
||
const [activeTab, setActiveTab] = useState<
|
||
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "sync" | "projects"
|
||
>(initialTab || "general");
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSyncing, setIsSyncing] = useState(false);
|
||
const [exportStartDate, setExportStartDate] = useState("");
|
||
const [exportEndDate, setExportEndDate] = useState("");
|
||
const [exportFields, setExportFields] = useState<Set<ExportFieldKey>>(
|
||
() => new Set(EXPORT_FIELDS.filter(f => f.defaultOn).map(f => f.key))
|
||
);
|
||
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 [newProjectIcon, setNewProjectIcon] = useState("📁");
|
||
const [showNewProjectIconPicker, setShowNewProjectIconPicker] = useState(false);
|
||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||
const [editProjectName, setEditProjectName] = useState("");
|
||
const [editProjectColor, setEditProjectColor] = useState("");
|
||
const [editProjectIcon, setEditProjectIcon] = useState("");
|
||
const [showEditProjectIconPicker, setShowEditProjectIconPicker] = useState(false);
|
||
const [weatherSearchResults, setWeatherSearchResults] = useState<any[]>([]);
|
||
|
||
// 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);
|
||
};
|
||
|
||
// profile state removed (centralized in parent)
|
||
|
||
const t = translations[profile.language || "en"] || translations["en"];
|
||
|
||
// --- Auto-save helpers: save settings immediately on change ---
|
||
// For discrete inputs (checkbox, select, button) — save right away
|
||
const saveField = (key: string, value: any) => {
|
||
setProfile((p: any) => ({ ...p, [key]: value }));
|
||
saveSetting(key, value);
|
||
};
|
||
// For continuous inputs (text, number, color picker) — debounce 500ms
|
||
const debouncedTimers = useRef<Record<string, NodeJS.Timeout>>({});
|
||
const saveFieldDebounced = (key: string, value: any) => {
|
||
setProfile((p: any) => ({ ...p, [key]: value }));
|
||
if (debouncedTimers.current[key]) clearTimeout(debouncedTimers.current[key]);
|
||
debouncedTimers.current[key] = setTimeout(() => saveSetting(key, value), 500);
|
||
};
|
||
// For standalone state + saveSetting (showSomeday, showTimeGrid, etc.)
|
||
const saveStateAndSetting = (setter: (v: any) => void, key: string, value: any) => {
|
||
setter(value);
|
||
saveSetting(key, value);
|
||
};
|
||
|
||
// Load fonts for preview
|
||
// Font loading moved to top level WeeklyView component
|
||
|
||
useEffect(() => {
|
||
setIsLoading(false);
|
||
// Trigger slide-in after mount
|
||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
const handleClose = () => {
|
||
setIsVisible(false);
|
||
setTimeout(onClose, 300);
|
||
};
|
||
|
||
|
||
const handleUpdateConnections = async (updatedConnections: any[]) => {
|
||
onUpdateConnections(updatedConnections);
|
||
};
|
||
|
||
const handleRemoveConnection = async (connectionId: string) => {
|
||
await onRemoveConnection(connectionId);
|
||
};
|
||
|
||
|
||
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 handleNotionConnect = () => {
|
||
window.location.href = "/api/calendar/notion/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: "space-around",
|
||
flexWrap: "nowrap",
|
||
gap: "0",
|
||
borderBottom: "1px solid var(--weekly-border, #eee)",
|
||
padding: "0",
|
||
}}
|
||
>
|
||
{([
|
||
{ 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: "sync", icon: <ArrowLeftRight size={18} />, label: t.calendarSync || "Sync" },
|
||
{ key: "projects", icon: <FolderOpen size={18} />, label: t.projects || "Projects" },
|
||
{ 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 8px",
|
||
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: "16px" }}
|
||
>
|
||
{/* ── General settings (not view-specific) ── */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", paddingBottom: "16px", borderBottom: "1px solid var(--weekly-border, #e5e7eb)" }}>
|
||
{/* Menu Position */}
|
||
<div>
|
||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
||
{profile.language === "de" ? "Menüposition" : "Menu Position"}
|
||
</label>
|
||
<div className="mt-1 flex gap-2">
|
||
{[
|
||
{ value: "left", label: profile.language === "de" ? "Links (Seitenleiste)" : "Left (Sidebar)" },
|
||
{ value: "top", label: profile.language === "de" ? "Oben (Kopfleiste)" : "Top (Header)" },
|
||
].map(({ value, label }) => (
|
||
<button
|
||
key={value}
|
||
onClick={() => {
|
||
setProfile((p: any) => ({ ...p, menuPosition: value }));
|
||
saveSetting("menuPosition", value);
|
||
}}
|
||
style={{
|
||
flex: 1,
|
||
padding: "6px 10px",
|
||
fontSize: "0.8rem",
|
||
borderRadius: "6px",
|
||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||
cursor: "pointer",
|
||
fontWeight: (profile.menuPosition || "left") === value ? 700 : 400,
|
||
background: (profile.menuPosition || "left") === value ? "var(--weekly-text, #333)" : "transparent",
|
||
color: (profile.menuPosition || "left") === value ? "#fff" : "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ marginTop: "10px" }}>
|
||
<label style={{ fontSize: "0.78rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
||
{profile.language === "de" ? "Steuerung in der Kopfleiste" : "Header Controls"}
|
||
</label>
|
||
<div className="mt-1 flex gap-2">
|
||
{[
|
||
{ value: true, label: profile.language === "de" ? "Anzeigen" : "Show" },
|
||
{ value: false, label: profile.language === "de" ? "Ausblenden" : "Hide" },
|
||
].map(({ value, label }) => {
|
||
const isActive = (profile.showHeaderControls !== false) === value;
|
||
return (
|
||
<button
|
||
key={String(value)}
|
||
onClick={() => {
|
||
setProfile((p: any) => ({ ...p, showHeaderControls: value }));
|
||
saveSetting("showHeaderControls", value);
|
||
}}
|
||
style={{
|
||
flex: 1,
|
||
padding: "6px 10px",
|
||
fontSize: "0.8rem",
|
||
borderRadius: "6px",
|
||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||
cursor: "pointer",
|
||
fontWeight: isActive ? 700 : 400,
|
||
background: isActive ? "var(--weekly-text, #333)" : "transparent",
|
||
color: isActive ? "#fff" : "var(--weekly-settings-label)",
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* Header Display */}
|
||
<div>
|
||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
||
{t.headerDisplay}
|
||
</label>
|
||
<div className="mt-1">
|
||
<select
|
||
value={profile.headerDisplay || "kw"}
|
||
onChange={(e) => {
|
||
const val = e.target.value as any;
|
||
setProfile({ ...profile, headerDisplay: val });
|
||
saveSetting("headerDisplay", val);
|
||
}}
|
||
className="weekly-input w-full p-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded"
|
||
>
|
||
<option value="kw">{t.headerDisplayKW}</option>
|
||
<option value="month">{t.headerDisplayMonth}</option>
|
||
<option value="month_year">{t.headerDisplayMonthYear}</option>
|
||
<option value="date">{t.headerDisplayDate}</option>
|
||
<option value="current_day">{t.headerDisplayCurrentDay}</option>
|
||
<option value="custom">{t.headerDisplayCustom}</option>
|
||
<option value="none">{t.headerDisplayNone}</option>
|
||
</select>
|
||
</div>
|
||
{profile.headerDisplay === "current_day" && (
|
||
<div className="mt-2">
|
||
<label style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", display: "block", marginBottom: "4px" }}>
|
||
{t.headerCurrentDayFormatLabel}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={profile.headerCurrentDayFormat || ""}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setProfile({ ...profile, headerCurrentDayFormat: val });
|
||
saveSetting("headerCurrentDayFormat", val);
|
||
}}
|
||
placeholder="DDD, DD. MMMM YYYY"
|
||
className="w-full px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
/>
|
||
<div className="mt-1 text-[10px] text-gray-500 leading-tight">
|
||
Tokens: DDDD (Montag), DDD (Mo.), DD (30), MMMM (März), MMM (Mär), MM (03), YYYY (2026)
|
||
</div>
|
||
</div>
|
||
)}
|
||
{profile.headerDisplay === "custom" && (
|
||
<div className="mt-2">
|
||
<label style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", display: "block", marginBottom: "4px" }}>
|
||
{t.headerCustomFormatLabel}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={profile.headerCustomFormat || ""}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setProfile({ ...profile, headerCustomFormat: val });
|
||
saveSetting("headerCustomFormat", val);
|
||
}}
|
||
placeholder="KW WW | YYYY"
|
||
className="w-full px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
/>
|
||
<div className="mt-1 text-[10px] text-gray-500 leading-tight">
|
||
Tokens: WW (KW), YYYY (Jahr), MMMM (März), MM (03), DD (30), [TODAY] (Heute)
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Mobile Portrait/Landscape overrides */}
|
||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", display: "block", marginBottom: "4px" }}>
|
||
{t.headerMobilePortrait}
|
||
</label>
|
||
<select
|
||
value={profile.mobilePortraitHeaderDisplay || "current_day"}
|
||
onChange={(e) => {
|
||
const val = e.target.value as any;
|
||
setProfile({ ...profile, mobilePortraitHeaderDisplay: val });
|
||
saveSetting("mobilePortraitHeaderDisplay", val);
|
||
}}
|
||
className="weekly-input w-full p-1.5 text-xs border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded"
|
||
>
|
||
<option value="kw">{t.headerDisplayKW}</option>
|
||
<option value="month">{t.headerDisplayMonth}</option>
|
||
<option value="month_year">{t.headerDisplayMonthYear}</option>
|
||
<option value="date">{t.headerDisplayDate}</option>
|
||
<option value="current_day">{t.headerDisplayCurrentDay}</option>
|
||
<option value="custom">{t.headerDisplayCustom}</option>
|
||
<option value="none">{t.headerDisplayNone}</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", display: "block", marginBottom: "4px" }}>
|
||
{t.headerMobileLandscape}
|
||
</label>
|
||
<select
|
||
value={profile.mobileLandscapeHeaderDisplay || "kw"}
|
||
onChange={(e) => {
|
||
const val = e.target.value as any;
|
||
setProfile({ ...profile, mobileLandscapeHeaderDisplay: val });
|
||
saveSetting("mobileLandscapeHeaderDisplay", val);
|
||
}}
|
||
className="weekly-input w-full p-1.5 text-xs border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded"
|
||
>
|
||
<option value="kw">{t.headerDisplayKW}</option>
|
||
<option value="month">{t.headerDisplayMonth}</option>
|
||
<option value="month_year">{t.headerDisplayMonthYear}</option>
|
||
<option value="date">{t.headerDisplayDate}</option>
|
||
<option value="current_day">{t.headerDisplayCurrentDay}</option>
|
||
<option value="custom">{t.headerDisplayCustom}</option>
|
||
<option value="none">{t.headerDisplayNone}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Push Notifications */}
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<input
|
||
type="checkbox"
|
||
id="notificationsEnabled"
|
||
checked={profile.notificationsEnabled || false}
|
||
onChange={async (e) => {
|
||
const enabled = e.target.checked;
|
||
if (enabled) {
|
||
try {
|
||
const { isNotificationSupported, requestNotificationPermission, registerServiceWorker, subscribeToPush, sendSubscriptionToServer } = await import('@/lib/push-notifications');
|
||
if (!isNotificationSupported()) {
|
||
alert('Push notifications are not supported in this browser.');
|
||
return;
|
||
}
|
||
const permission = await requestNotificationPermission();
|
||
if (permission !== 'granted') {
|
||
alert('Notification permission was denied.');
|
||
return;
|
||
}
|
||
const registration = await registerServiceWorker();
|
||
if (!registration) { alert('Failed to register service worker.'); return; }
|
||
const subscription = await subscribeToPush(registration);
|
||
if (!subscription) { alert('Failed to subscribe to push notifications.'); return; }
|
||
const sent = await sendSubscriptionToServer(subscription);
|
||
if (!sent) { alert('Failed to save subscription.'); return; }
|
||
saveField("notificationsEnabled", true);
|
||
} catch (err) {
|
||
console.error('Push notification setup failed:', err);
|
||
alert('Failed to enable notifications.');
|
||
}
|
||
} else {
|
||
try {
|
||
const { unsubscribeFromPush } = await import('@/lib/push-notifications');
|
||
const registration = await navigator.serviceWorker.ready;
|
||
await unsubscribeFromPush(registration);
|
||
} catch (err) {
|
||
console.error('Unsubscribe failed:', err);
|
||
}
|
||
saveField("notificationsEnabled", false);
|
||
}
|
||
}}
|
||
style={{ width: "16px", height: "16px" }}
|
||
/>
|
||
<label htmlFor="notificationsEnabled" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
|
||
{(profile.language || "en") === "de" ? "Push-Benachrichtigungen" : "Push Notifications"}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── View Style Tabs ── */}
|
||
<div>
|
||
<div style={{
|
||
display: "flex",
|
||
borderBottom: "2px solid var(--weekly-border, #e5e7eb)",
|
||
gap: "0",
|
||
}}>
|
||
{([
|
||
{ key: "simple", label: t.simpleView, icon: <Calendar size={14} /> },
|
||
{ key: "calendar", label: t.calendarView, icon: <CalendarDays size={14} /> },
|
||
{ key: "list", label: t.listView, icon: <ListTodo size={14} /> },
|
||
{ key: "kanban", label: t.kanbanView, icon: <Kanban size={14} /> },
|
||
] as const).map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => {
|
||
setViewStyle(tab.key);
|
||
saveSetting("viewStyle", tab.key);
|
||
if (tab.key === "simple" || tab.key === "calendar") {
|
||
setShowTimeGrid(true);
|
||
saveSetting("showTimeGrid", true);
|
||
} else if (tab.key === "list") {
|
||
setShowTimeGrid(false);
|
||
saveSetting("showTimeGrid", false);
|
||
}
|
||
}}
|
||
style={{
|
||
flex: 1,
|
||
padding: "10px 8px",
|
||
fontSize: "0.8rem",
|
||
fontWeight: viewStyle === tab.key ? 700 : 400,
|
||
color: viewStyle === tab.key ? "var(--weekly-text, #333)" : "var(--weekly-text-light, #9ca3af)",
|
||
background: "none",
|
||
border: "none",
|
||
borderBottom: viewStyle === tab.key ? "2px solid var(--weekly-text, #333)" : "2px solid transparent",
|
||
marginBottom: "-2px",
|
||
cursor: "pointer",
|
||
transition: "all 0.15s",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: "4px",
|
||
}}
|
||
>
|
||
{tab.icon}
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── View-specific settings ── */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||
|
||
{/* Time grid settings — for simple & calendar views */}
|
||
{(viewStyle === "simple" || viewStyle === "calendar") && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||
<div>
|
||
<label style={{ display: "block", fontSize: "0.9rem", fontWeight: 600, marginBottom: "4px" }}>
|
||
{t.timeSlotDuration}
|
||
</label>
|
||
<select
|
||
value={perView.getEffective("cellDuration", cellDuration) as number}
|
||
onChange={(e) => {
|
||
const val = Number(e.target.value) as CellDuration;
|
||
perView.saveViewSetting("cellDuration", val, true);
|
||
}}
|
||
className="weekly-input"
|
||
style={{ width: "100%", padding: "8px", border: "1px solid #ddd", borderRadius: "4px" }}
|
||
>
|
||
<option value={15}>15 min</option>
|
||
<option value={20}>20 min</option>
|
||
<option value={30}>30 min</option>
|
||
<option value={60}>1 hour</option>
|
||
</select>
|
||
</div>
|
||
|
||
|
||
<div>
|
||
<label style={{ display: "block", fontSize: "0.9rem", fontWeight: 600, marginBottom: "4px" }}>
|
||
{t.hourLabelFormat}
|
||
</label>
|
||
<select
|
||
value={perView.getEffective("hourLabelFormat", hourLabelFormat) as string}
|
||
onChange={(e) => {
|
||
const fmt = e.target.value as "short" | "full";
|
||
perView.saveViewSetting("hourLabelFormat", fmt, true);
|
||
}}
|
||
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>
|
||
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<input
|
||
type="checkbox" id="showSubHourSlots"
|
||
checked={perView.getEffective("showSubHourSlots", showSubHourSlots) as boolean}
|
||
onChange={(e) => {
|
||
perView.saveViewSetting("showSubHourSlots", e.target.checked, true);
|
||
}}
|
||
style={{ width: "16px", height: "16px" }}
|
||
/>
|
||
<label htmlFor="showSubHourSlots" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
|
||
{t.showSubhourLabels}
|
||
</label>
|
||
</div>
|
||
|
||
{/* Weather Settings */}
|
||
<div style={{ marginTop: "8px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "12px" }}>
|
||
<label style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "8px", cursor: "pointer" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={perView.getEffective("weatherEnabled", profile.weatherEnabled || false) as boolean}
|
||
onChange={(e) => {
|
||
setProfile({ ...profile, weatherEnabled: e.target.checked });
|
||
saveSetting("weatherEnabled", e.target.checked);
|
||
perView.saveViewSetting("weatherEnabled", e.target.checked, true);
|
||
}}
|
||
style={{ width: "16px", height: "16px" }}
|
||
/>
|
||
<span style={{ fontSize: "0.9rem", fontWeight: 600 }}>
|
||
{profile.language === "de" ? "Wetter anzeigen" : "Show weather"}
|
||
</span>
|
||
</label>
|
||
{(perView.getEffective("weatherEnabled", profile.weatherEnabled || false) as boolean) && (() => {
|
||
const recentCities: Array<{ name: string; country: string; admin1?: string; lat: number; lon: number }> = Array.isArray(profile.weatherRecentCities) ? profile.weatherRecentCities : [];
|
||
const selectCity = (city: { name: string; country: string; admin1?: string; lat: number; lon: number }) => {
|
||
const locationStr = `${city.name}, ${city.country}`;
|
||
setProfile({ ...profile, weatherLat: city.lat, weatherLon: city.lon, weatherLocation: locationStr });
|
||
saveSetting("weatherLat", city.lat);
|
||
saveSetting("weatherLon", city.lon);
|
||
saveSetting("weatherLocation", locationStr);
|
||
// Add to recent cities (deduplicate by lat+lon, keep max 8)
|
||
const entry = { name: city.name, country: city.country, ...(city.admin1 ? { admin1: city.admin1 } : {}), lat: city.lat, lon: city.lon };
|
||
const filtered = recentCities.filter((c: any) => !(Math.abs(c.lat - city.lat) < 0.01 && Math.abs(c.lon - city.lon) < 0.01));
|
||
const updated = [entry, ...filtered].slice(0, 8);
|
||
setProfile((p: any) => ({ ...p, weatherRecentCities: updated }));
|
||
saveSetting("weatherRecentCities", updated);
|
||
setWeatherSearchResults([]);
|
||
};
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginLeft: "24px" }}>
|
||
<input
|
||
type="text"
|
||
placeholder={profile.language === "de" ? "Stadt suchen..." : "Search city..."}
|
||
className="weekly-input"
|
||
style={{ padding: "6px 10px", fontSize: "0.85rem" }}
|
||
onChange={async (e) => {
|
||
const q = e.target.value;
|
||
if (q.length < 2) { setWeatherSearchResults([]); return; }
|
||
try {
|
||
const res = await fetch(`/api/weather/geocode?q=${encodeURIComponent(q)}`);
|
||
const data = await res.json();
|
||
setWeatherSearchResults(data.results || []);
|
||
} catch { setWeatherSearchResults([]); }
|
||
}}
|
||
/>
|
||
{weatherSearchResults.length > 0 && (
|
||
<div style={{ border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "8px", overflow: "hidden" }}>
|
||
{weatherSearchResults.map((r: any, i: number) => (
|
||
<button
|
||
key={i}
|
||
onClick={() => selectCity(r)}
|
||
style={{ display: "block", width: "100%", padding: "8px 12px", textAlign: "left", border: "none", borderBottom: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-secondary, #f9fafb)", cursor: "pointer", fontSize: "0.85rem" }}
|
||
>
|
||
{r.name}{r.admin1 ? `, ${r.admin1}` : ""}, {r.country} <span style={{ color: "#888", fontSize: "0.75rem" }}>({r.lat.toFixed(2)}, {r.lon.toFixed(2)})</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{profile.weatherLocation && (
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 12px", borderRadius: "8px", background: "var(--bg-secondary, #f9fafb)" }}>
|
||
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>📍 {profile.weatherLocation}</span>
|
||
<span style={{ fontSize: "0.75rem", color: "#888" }}>({profile.weatherLat?.toFixed(2)}, {profile.weatherLon?.toFixed(2)})</span>
|
||
</div>
|
||
)}
|
||
{recentCities.length > 0 && (
|
||
<div>
|
||
<div style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--weekly-text-light, #9ca3af)", marginBottom: "4px" }}>
|
||
{profile.language === "de" ? "Letzte Städte" : "Recent cities"}
|
||
</div>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||
{recentCities.map((c: any, i: number) => {
|
||
const isActive = profile.weatherLat && Math.abs(c.lat - profile.weatherLat) < 0.01 && profile.weatherLon && Math.abs(c.lon - profile.weatherLon) < 0.01;
|
||
return (
|
||
<button
|
||
key={i}
|
||
onClick={() => selectCity(c)}
|
||
style={{
|
||
padding: "4px 10px", fontSize: "0.8rem", borderRadius: "999px",
|
||
border: isActive ? "1.5px solid var(--weekly-accent, #0ea5e9)" : "1px solid var(--weekly-border, #e5e7eb)",
|
||
background: isActive ? "var(--weekly-accent, #0ea5e9)" : "var(--bg-secondary, #f9fafb)",
|
||
color: isActive ? "#fff" : "var(--weekly-text, #333)",
|
||
cursor: "pointer", fontWeight: isActive ? 600 : 400,
|
||
}}
|
||
>
|
||
{c.name}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Weather display options */}
|
||
<div style={{ marginTop: "8px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "8px" }}>
|
||
<div style={{ fontSize: "0.8rem", fontWeight: 600, marginBottom: "6px", color: "var(--weekly-text-light, #666)" }}>
|
||
{profile.language === "de" ? "Angezeigte Daten" : "Display data"}
|
||
</div>
|
||
{([
|
||
{ key: "icon" as WeatherDisplayKey, label: profile.language === "de" ? "Wettersymbol" : "Weather icon", icon: "☀️" },
|
||
{ key: "temp" as WeatherDisplayKey, label: profile.language === "de" ? "Temperatur" : "Temperature", icon: "🌡️" },
|
||
{ key: "feelsLike" as WeatherDisplayKey, label: profile.language === "de" ? "Gefühlte Temp." : "Feels like", icon: "🤒" },
|
||
{ key: "wind" as WeatherDisplayKey, label: profile.language === "de" ? "Windgeschwindigkeit" : "Wind speed", icon: "🌬️" },
|
||
{ key: "gusts" as WeatherDisplayKey, label: profile.language === "de" ? "Windböen" : "Wind gusts", icon: "💨" },
|
||
{ key: "precipProb" as WeatherDisplayKey, label: profile.language === "de" ? "Regenwahrscheinl." : "Rain probability", icon: "🌧️" },
|
||
{ key: "precip" as WeatherDisplayKey, label: profile.language === "de" ? "Niederschlag (mm)" : "Precipitation (mm)", icon: "💦" },
|
||
{ key: "humidity" as WeatherDisplayKey, label: profile.language === "de" ? "Luftfeuchtigkeit" : "Humidity", icon: "💧" },
|
||
{ key: "uv" as WeatherDisplayKey, label: "UV Index", icon: "☀️" },
|
||
]).map(({ key, label, icon }) => {
|
||
const current = (perView.getEffective("weatherDisplay", WEATHER_DISPLAY_DEFAULTS) || WEATHER_DISPLAY_DEFAULTS) as WeatherDisplayKey[];
|
||
const checked = current.includes(key);
|
||
return (
|
||
<label key={key} style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", padding: "3px 0", fontSize: "0.82rem" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={() => {
|
||
const updated = checked ? current.filter(k => k !== key) : [...current, key];
|
||
perView.saveViewSetting("weatherDisplay", updated.length > 0 ? updated : ["icon"], true);
|
||
}}
|
||
style={{ width: "14px", height: "14px" }}
|
||
/>
|
||
<span>{icon}</span>
|
||
<span>{label}</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Per-view display settings */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "10px", borderTop: (viewStyle === "simple" || viewStyle === "calendar") ? "1px solid var(--weekly-border, #e5e7eb)" : "none", paddingTop: (viewStyle === "simple" || viewStyle === "calendar") ? "12px" : "0" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<input type="checkbox" id="showSomeday"
|
||
checked={perView.getEffective("showSomeday", showSomeday) as boolean}
|
||
onChange={(e) => {
|
||
perView.saveViewSetting("showSomeday", e.target.checked, true);
|
||
}}
|
||
style={{ width: "16px", height: "16px" }} />
|
||
<label htmlFor="showSomeday" style={{ fontSize: "0.9rem", fontWeight: 600 }}>{t.showSomeday}</label>
|
||
</div>
|
||
|
||
{viewStyle !== "kanban" && (
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<input type="checkbox" id="showAllDay"
|
||
checked={perView.getEffective("showAllDayEvents", showAllDay) as boolean}
|
||
onChange={(e) => {
|
||
perView.saveViewSetting("showAllDayEvents", e.target.checked, true);
|
||
}}
|
||
style={{ width: "16px", height: "16px" }} />
|
||
<label htmlFor="showAllDay" style={{ fontSize: "0.9rem", fontWeight: 600 }}>{t.showAllDay}</label>
|
||
</div>
|
||
)}
|
||
|
||
{viewStyle !== "kanban" && (perView.getEffective("showAllDayEvents", showAllDay) as boolean) && (
|
||
<div style={{ marginLeft: "24px" }}>
|
||
<select
|
||
value={perView.getEffective("allDayPosition", allDayPosition) as string}
|
||
onChange={(e) => {
|
||
const pos = e.target.value as "above" | "below";
|
||
perView.saveViewSetting("allDayPosition", pos, true);
|
||
}}
|
||
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>
|
||
)}
|
||
|
||
{viewStyle !== "kanban" && (
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<input type="checkbox" id="autoRolling" checked={profile.autoRolling || false}
|
||
onChange={(e) => saveField("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) => {
|
||
saveField("showTaskCheckboxes", e.target.checked);
|
||
perView.saveViewSetting("showTaskCheckboxes", e.target.checked, false);
|
||
}}
|
||
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="showProjectIcons"
|
||
checked={profile.showProjectIcons || false}
|
||
onChange={(e) => {
|
||
saveField("showProjectIcons", e.target.checked);
|
||
perView.saveViewSetting("showProjectIcons", e.target.checked, false);
|
||
}}
|
||
style={{ width: "16px", height: "16px" }} />
|
||
<label htmlFor="showProjectIcons" style={{ fontSize: "0.9rem", fontWeight: 600 }}>{t.showProjectIcons}</label>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<input type="checkbox" id="showPriorityIcons"
|
||
checked={profile.showPriorityIcons !== false}
|
||
onChange={(e) => {
|
||
saveField("showPriorityIcons", e.target.checked);
|
||
perView.saveViewSetting("showPriorityIcons", e.target.checked, false);
|
||
}}
|
||
style={{ width: "16px", height: "16px" }} />
|
||
<label htmlFor="showPriorityIcons" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
|
||
{profile.language === "de" ? "Prioritäts-Icons anzeigen" : "Show Priority Icons"}
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<label htmlFor="priorityStyle" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
|
||
{profile.language === "de" ? "Prioritäts-Stil" : "Priority Style"}
|
||
</label>
|
||
<select
|
||
id="priorityStyle"
|
||
value={profile.priorityStyle || "eisenhower"}
|
||
onChange={(e) => saveField("priorityStyle", e.target.value)}
|
||
className="weekly-input"
|
||
style={{ padding: "4px 8px", fontSize: "0.85rem", borderRadius: "6px", border: "1px solid var(--weekly-border, #e5e7eb)" }}
|
||
>
|
||
<option value="eisenhower">Eisenhower</option>
|
||
<option value="abcde">ABCDE</option>
|
||
<option value="ivylee">Ivy Lee</option>
|
||
<option value="pareto">80/20 (Pareto)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{viewStyle !== "kanban" && (
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<input type="checkbox" id="protectEventTimes" checked={profile.protectEventTimes || false}
|
||
onChange={(e) => saveField("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="showCompletedTasks"
|
||
checked={perView.getEffective("showCompletedTasks", profile.showCompletedTasks !== false) as boolean}
|
||
onChange={(e) => {
|
||
perView.saveViewSetting("showCompletedTasks", e.target.checked, true);
|
||
}}
|
||
style={{ width: "16px", height: "16px" }} />
|
||
<label htmlFor="showCompletedTasks" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
|
||
{profile.language === "de" ? "Erledigte Aufgaben anzeigen" : "Show completed tasks"}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
) : activeTab === "localisation" ? (
|
||
<div
|
||
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
|
||
>
|
||
<h3 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
|
||
{t.localisation || "Localisation"}
|
||
</h3>
|
||
|
||
{/* 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); saveSetting("weekStartDay", 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); saveSetting("weekStartDay", 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) => saveField("weekdayFormat", 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)" }}
|
||
>
|
||
<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) => saveFieldDebounced("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) => saveField("weekdayCase", 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)" }}
|
||
>
|
||
<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>
|
||
<SearchableDropdown
|
||
value={profile.language || "de"}
|
||
onChange={(v) => saveField("language", v)}
|
||
options={[
|
||
{ value: "en", label: "English", leading: <FlagIcon code="en" width={20} height={14} /> },
|
||
{ value: "de", label: "Deutsch", leading: <FlagIcon code="de" width={20} height={14} /> },
|
||
{ value: "fr", label: "Français", leading: <FlagIcon code="fr" width={20} height={14} /> },
|
||
{ value: "es", label: "Español", leading: <FlagIcon code="es" width={20} height={14} /> },
|
||
{ value: "it", label: "Italiano", leading: <FlagIcon code="it" width={20} height={14} /> },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
{t.timezone}
|
||
</label>
|
||
<SearchableDropdown
|
||
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||
onChange={(v) => saveField("timezone", v)}
|
||
searchable
|
||
searchPlaceholder={profile.language === "de" ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
|
||
emptyText={profile.language === "de" ? "Keine Treffer" : "No matches"}
|
||
options={[...TIMEZONE_OPTIONS]
|
||
.sort((a, b) => {
|
||
const oa = getOffsetMinutes(a.zone);
|
||
const ob = getOffsetMinutes(b.zone);
|
||
if (oa !== ob) return oa - ob;
|
||
return a.code.localeCompare(b.code);
|
||
})
|
||
.map((opt) => {
|
||
const off = formatOffset(getOffsetMinutes(opt.zone));
|
||
return {
|
||
value: opt.zone,
|
||
label: `${opt.code} — ${opt.label}`,
|
||
secondary: `UTC${off}`,
|
||
// Match against IANA name (zone), code, label,
|
||
// and the offset in several styles.
|
||
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
|
||
};
|
||
})}
|
||
/>
|
||
{/* Multi-timezone (Punkt 6) */}
|
||
<ExtraTimezonesEditor
|
||
profile={profile}
|
||
setProfile={setProfile}
|
||
saveSetting={saveSetting}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
marginBottom: "4px",
|
||
}}
|
||
>
|
||
{t.dateFormat}
|
||
</label>
|
||
<select
|
||
value={profile.dateFormat}
|
||
onChange={(e) => saveField("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) => saveField("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>
|
||
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "12px" }}>
|
||
<input type="checkbox" id="showCalendarProviderIcon"
|
||
checked={profile.showCalendarProviderIcon || false}
|
||
onChange={(e) => saveField("showCalendarProviderIcon", e.target.checked)}
|
||
style={{ width: "16px", height: "16px" }} />
|
||
<label htmlFor="showCalendarProviderIcon" style={{ fontSize: "0.9rem", fontWeight: 600 }}>
|
||
{t.showProviderIcon}
|
||
</label>
|
||
</div>
|
||
|
||
{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} />
|
||
: conn.provider === "notion"
|
||
? <FontAwesomeIcon icon={faNotion} />
|
||
: <FontAwesomeIcon icon={faMicrosoft} />}
|
||
</span>
|
||
{conn.provider === "google"
|
||
? "Google Calendar"
|
||
: conn.provider === "apple"
|
||
? "Apple Calendar"
|
||
: conn.provider === "synology"
|
||
? "Synology Calendar"
|
||
: conn.provider === "notion"
|
||
? "Notion"
|
||
: "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 Color + Name */}
|
||
<span
|
||
style={{
|
||
flex: 1,
|
||
fontSize: "0.9rem",
|
||
color: "var(--weekly-text)",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "6px",
|
||
}}
|
||
>
|
||
<span style={{
|
||
width: "10px", height: "10px", borderRadius: "50%", flexShrink: 0,
|
||
backgroundColor: cal.backgroundColor || cal.color || "#3b82f6",
|
||
}} />
|
||
{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
|
||
: conn.provider === "notion"
|
||
? t.selectionAfterConnect
|
||
: 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>
|
||
<button
|
||
onClick={handleNotionConnect}
|
||
className="calendar-connect-btn"
|
||
>
|
||
<FontAwesomeIcon icon={faNotion} className="mr-2" /> {t.connectNotion || "Connect Notion"}
|
||
</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" }}
|
||
>
|
||
{/* Mobile Font Scale */}
|
||
<div style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
}}>
|
||
<label style={{
|
||
display: "block", fontSize: "0.85rem", fontWeight: 600,
|
||
marginBottom: "8px", color: "var(--weekly-settings-label)",
|
||
}}>
|
||
{(profile.language || "en") === "de" ? "Mobile Schriftgröße" : "Mobile Font Scale"}
|
||
</label>
|
||
<div style={{ display: "flex", gap: "6px" }}>
|
||
{[
|
||
{ label: "75%", value: 0.75 },
|
||
{ label: "85%", value: 0.85 },
|
||
{ label: "100%", value: 1.0 },
|
||
{ label: "115%", value: 1.15 },
|
||
{ label: "130%", value: 1.3 },
|
||
].map(opt => (
|
||
<button
|
||
key={opt.value}
|
||
onClick={() => {
|
||
setProfile({ ...profile, mobileFontScale: opt.value });
|
||
saveSetting("mobileFontScale", opt.value);
|
||
}}
|
||
style={{
|
||
flex: 1, padding: "6px 4px", borderRadius: "6px",
|
||
border: (profile.mobileFontScale || 1.0) === opt.value
|
||
? "2px solid var(--weekly-teal, #0ea5e9)"
|
||
: "1px solid var(--weekly-border, #ddd)",
|
||
background: (profile.mobileFontScale || 1.0) === opt.value
|
||
? "var(--weekly-teal, #0ea5e9)" : "transparent",
|
||
color: (profile.mobileFontScale || 1.0) === opt.value
|
||
? "white" : "var(--weekly-text)",
|
||
fontSize: "0.8rem", fontWeight: 600, cursor: "pointer",
|
||
}}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span style={{ fontSize: "0.7rem", color: "var(--weekly-text-light)", marginTop: "4px", display: "block" }}>
|
||
{(profile.language || "en") === "de"
|
||
? "Skaliert alle Schriften auf Mobilgeräten (< 768px)"
|
||
: "Scales all fonts on mobile devices (< 768px)"}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 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) =>
|
||
saveField("dateLayout", 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)",
|
||
}}
|
||
>
|
||
<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) =>
|
||
saveField("mobileDateLayout", 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)",
|
||
}}
|
||
>
|
||
<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) =>
|
||
saveField("dateAlignment", 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)",
|
||
}}
|
||
>
|
||
<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) =>
|
||
saveField("dateVerticalAlign", 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)",
|
||
}}
|
||
>
|
||
<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) =>
|
||
saveFieldDebounced("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) => saveFieldDebounced("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) => {
|
||
const val = e.target.value === "__custom__" ? "__custom__" : e.target.value;
|
||
saveField("headlineFont", val);
|
||
}}
|
||
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) => saveFieldDebounced("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) => saveFieldDebounced("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) => saveField("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) => saveFieldDebounced("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) => {
|
||
const val = e.target.value === "__custom__" ? "__custom__" : e.target.value;
|
||
saveField("dateFontFamily", val);
|
||
}}
|
||
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) => saveFieldDebounced("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) => saveFieldDebounced("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) => saveField("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) => saveFieldDebounced("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) => {
|
||
const val = e.target.value === "__custom__" ? "__custom__" : e.target.value;
|
||
saveField("taskFontFamily", val);
|
||
saveField("timeTaskFontFamily", val);
|
||
}}
|
||
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) => {
|
||
const val = e.target.value || "__custom__";
|
||
saveFieldDebounced("taskFontFamily", val);
|
||
saveFieldDebounced("timeTaskFontFamily", val);
|
||
}}
|
||
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) => {
|
||
saveFieldDebounced("taskFontSize", e.target.value);
|
||
saveFieldDebounced("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) => {
|
||
saveField("taskFontWeight", e.target.value);
|
||
saveField("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) => {
|
||
const val = e.target.value === "__custom__" ? "__custom__" : e.target.value;
|
||
saveField("eventFontFamily", val);
|
||
}}
|
||
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) => saveFieldDebounced("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) => saveFieldDebounced("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) => saveField("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) => {
|
||
const val = e.target.value === "__custom__" ? "__custom__" : e.target.value;
|
||
saveField("goalFontFamily", val);
|
||
}}
|
||
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) => saveFieldDebounced("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) => saveFieldDebounced("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) => saveField("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) => saveFieldDebounced("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) => {
|
||
const val = e.target.value === "__custom__" ? "__custom__" : e.target.value;
|
||
saveField("cwFontFamily", val);
|
||
}}
|
||
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) => saveFieldDebounced("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) => saveFieldDebounced("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) => saveField("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) => saveFieldDebounced("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) => {
|
||
const val = e.target.value === "__custom__" ? "__custom__" : e.target.value;
|
||
saveField("yearFontFamily", val);
|
||
}}
|
||
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) => saveFieldDebounced("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) => saveFieldDebounced("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) => saveField("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>
|
||
|
||
{/* Terminal Theme Import/Export */}
|
||
<div
|
||
style={{
|
||
background: "var(--weekly-settings-item-bg)",
|
||
padding: "16px",
|
||
borderRadius: "8px",
|
||
marginBottom: "12px",
|
||
border: "1px solid var(--weekly-border)",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "block",
|
||
fontSize: "0.95rem",
|
||
fontWeight: 700,
|
||
color: "var(--weekly-settings-title)",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
Terminal Color Theme
|
||
</label>
|
||
<p style={{ fontSize: "0.8rem", color: "var(--weekly-settings-label)", marginBottom: "16px" }}>
|
||
Import or Export standard Terminal 16-color JSON themes (e.g. Gogh, terminal.sexy) to completely change the app colors.
|
||
</p>
|
||
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
|
||
{/* Light Theme */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", padding: "12px", background: "rgba(0,0,0,0.03)", borderRadius: "6px" }}>
|
||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-title)" }}>☀️ Light Mode Theme</label>
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<label className="weekly-btn-secondary" style={{ flex: 1, textAlign: "center", cursor: "pointer", fontSize: "0.8rem", padding: "6px" }}>
|
||
Import JSON
|
||
<input
|
||
type="file"
|
||
accept=".json"
|
||
style={{ display: "none" }}
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = async (evt) => {
|
||
try {
|
||
const text = evt.target?.result as string;
|
||
let json = JSON.parse(text);
|
||
|
||
// Normalizing standard terminal formats to a consistent internal format
|
||
const normalized: any = {
|
||
background: json.background,
|
||
foreground: json.foreground,
|
||
cursorColor: json.cursorColor || json.cursor,
|
||
};
|
||
|
||
// Handle Tabby-style colors array
|
||
if (Array.isArray(json.colors)) {
|
||
json.colors.forEach((c: string, i: number) => {
|
||
normalized[`color${i}`] = c;
|
||
});
|
||
} else {
|
||
// Handle Gogh/terminal.sexy color0...color15 keys
|
||
for (let i = 0; i < 16; i++) {
|
||
if (json[`color${i}`]) normalized[`color${i}`] = json[`color${i}`];
|
||
}
|
||
}
|
||
|
||
saveField("lightTheme", normalized);
|
||
} catch (err) {
|
||
alert("Invalid JSON format.");
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
<button
|
||
className="weekly-btn-outline"
|
||
onClick={() => {
|
||
// Fix: access profile from state properly
|
||
const currentTheme = (profile as any).lightTheme || {};
|
||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(currentTheme, null, 2));
|
||
const anchor = document.createElement("a");
|
||
anchor.href = dataStr;
|
||
anchor.download = "light-theme.json";
|
||
anchor.click();
|
||
}}
|
||
style={{ flex: 1, fontSize: "0.8rem", padding: "6px" }}
|
||
>
|
||
Export
|
||
</button>
|
||
</div>
|
||
{((profile as any).lightTheme) && (
|
||
<button
|
||
onClick={() => saveField("lightTheme", null)}
|
||
style={{ fontSize: "0.75rem", color: "#dc2626", background: "none", border: "none", cursor: "pointer", textAlign: "left", marginTop: "4px" }}
|
||
>
|
||
Clear Light Theme
|
||
</button>
|
||
)}
|
||
{/* Color 16-grid Preview */}
|
||
{(profile as any).lightTheme && (
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px", marginTop: "8px" }}>
|
||
{[...Array(16)].map((_, i) => (
|
||
<div key={i} title={`color${i}`} style={{ width: "100%", height: "12px", background: (profile as any).lightTheme[`color${i}`] || "#ccc", borderRadius: "2px" }} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Dark Theme */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", padding: "12px", background: "rgba(0,0,0,0.2)", borderRadius: "6px" }}>
|
||
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-title)" }}>🌙 Dark Mode Theme</label>
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<label className="weekly-btn-secondary" style={{ flex: 1, textAlign: "center", cursor: "pointer", fontSize: "0.8rem", padding: "6px" }}>
|
||
Import JSON
|
||
<input
|
||
type="file"
|
||
accept=".json"
|
||
style={{ display: "none" }}
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = async (evt) => {
|
||
try {
|
||
const text = evt.target?.result as string;
|
||
let json = JSON.parse(text);
|
||
|
||
const normalized: any = {
|
||
background: json.background,
|
||
foreground: json.foreground,
|
||
cursorColor: json.cursorColor || json.cursor,
|
||
};
|
||
|
||
if (Array.isArray(json.colors)) {
|
||
json.colors.forEach((c: string, i: number) => {
|
||
normalized[`color${i}`] = c;
|
||
});
|
||
} else {
|
||
for (let i = 0; i < 16; i++) {
|
||
if (json[`color${i}`]) normalized[`color${i}`] = json[`color${i}`];
|
||
}
|
||
}
|
||
|
||
saveField("darkTheme", normalized);
|
||
} catch (err) {
|
||
alert("Invalid JSON format.");
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
<button
|
||
className="weekly-btn-outline"
|
||
onClick={() => {
|
||
const currentTheme = (profile as any).darkTheme || {};
|
||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(currentTheme, null, 2));
|
||
const anchor = document.createElement("a");
|
||
anchor.href = dataStr;
|
||
anchor.download = "dark-theme.json";
|
||
anchor.click();
|
||
}}
|
||
style={{ flex: 1, fontSize: "0.8rem", padding: "6px" }}
|
||
>
|
||
Export
|
||
</button>
|
||
</div>
|
||
{((profile as any).darkTheme) && (
|
||
<button
|
||
onClick={() => saveField("darkTheme", null)}
|
||
style={{ fontSize: "0.75rem", color: "#ef4444", background: "none", border: "none", cursor: "pointer", textAlign: "left", marginTop: "4px" }}
|
||
>
|
||
Clear Dark Theme
|
||
</button>
|
||
)}
|
||
{/* Color 16-grid Preview */}
|
||
{(profile as any).darkTheme && (
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px", marginTop: "8px" }}>
|
||
{[...Array(16)].map((_, i) => (
|
||
<div key={i} title={`color${i}`} style={{ width: "100%", height: "12px", background: (profile as any).darkTheme[`color${i}`] || "#ccc", borderRadius: "2px" }} />
|
||
))}
|
||
</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) =>
|
||
saveFieldDebounced("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) =>
|
||
saveFieldDebounced("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) =>
|
||
saveFieldDebounced("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) =>
|
||
saveFieldDebounced("weekendColorSun", e.target.value)
|
||
}
|
||
style={{
|
||
width: "100%",
|
||
height: "30px",
|
||
cursor: "pointer",
|
||
border: "none",
|
||
background: "transparent",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* CSS Variables */}
|
||
<CssVariablesEditor profile={profile} setProfile={setProfile} saveSetting={saveSetting} />
|
||
|
||
{/* Custom CSS */}
|
||
<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: "4px",
|
||
}}
|
||
>
|
||
{profile.language === "de" ? "Eigenes CSS" : "Custom CSS"}
|
||
</label>
|
||
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 8px", opacity: 0.8 }}>
|
||
{profile.language === "de"
|
||
? "Wird nach dem Stylesheet geladen und kann jede Regel überschreiben."
|
||
: "Loaded after the stylesheet — can override any rule."}
|
||
</p>
|
||
<textarea
|
||
value={profile.customCss || ""}
|
||
onChange={(e) => saveFieldDebounced("customCss", e.target.value)}
|
||
rows={10}
|
||
spellCheck={false}
|
||
placeholder={".weekly-task-item.completed .weekly-task-text {\n color: #cccccc;\n}"}
|
||
style={{
|
||
width: "100%",
|
||
fontFamily: "monospace",
|
||
fontSize: "0.75rem",
|
||
background: "var(--weekly-settings-input-bg)",
|
||
color: "var(--weekly-settings-text)",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "6px",
|
||
padding: "8px",
|
||
resize: "vertical",
|
||
boxSizing: "border-box",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* All styling settings auto-save */}
|
||
</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);
|
||
saveSetting("showNextTask", 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) => saveFieldDebounced("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) => saveFieldDebounced("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={() => saveField("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={() => saveField("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) => saveField("goalFallbackType", e.target.value)}
|
||
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: any) => ({ ...p, quoteSourceUrls: newUrls }));
|
||
if (debouncedTimers.current["quoteSourceUrls"]) clearTimeout(debouncedTimers.current["quoteSourceUrls"]);
|
||
debouncedTimers.current["quoteSourceUrls"] = setTimeout(() => saveSetting("quoteSourceUrls", newUrls), 500);
|
||
}}
|
||
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);
|
||
saveField("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 || ""]), ""];
|
||
saveField("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>
|
||
|
||
{/* Preset sources */}
|
||
<div style={{ marginTop: "8px" }}>
|
||
<p style={{ fontSize: "0.75rem", color: "var(--weekly-text-light)", marginBottom: "6px" }}>
|
||
{profile.language === "de" ? "Bekannte Quellen (klicken zum Hinzufügen):" : "Known sources (click to add):"}
|
||
</p>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
|
||
{[
|
||
{ label: "ZenQuotes (EN)", url: "https://zenquotes.io/api/random" },
|
||
{ label: "Stoic Quotes (EN)", url: "https://stoic.tekloon.net/stoic-quote" },
|
||
{ label: "Quotable (EN)", url: "https://api.quotable.io/quotes/random" },
|
||
{ label: "Advice Slip (EN)", url: "https://api.adviceslip.com/advice" },
|
||
{ label: "Zitat-Service (DE)", url: "https://api.zitat-service.de/v1/quote?language=de" },
|
||
{ label: "Zitat-Service (EN)", url: "https://api.zitat-service.de/v1/quote?language=en" },
|
||
{ label: "Zitat-Service (ES)", url: "https://api.zitat-service.de/v1/quote?language=es" },
|
||
].map(({ label, url }) => {
|
||
const current: string[] = profile.quoteSourceUrls || [];
|
||
const already = current.includes(url);
|
||
return (
|
||
<button
|
||
key={url}
|
||
type="button"
|
||
disabled={already}
|
||
onClick={() => {
|
||
if (already) return;
|
||
const newUrls = [...current, url];
|
||
saveField("quoteSourceUrls", newUrls);
|
||
}}
|
||
style={{
|
||
padding: "3px 8px",
|
||
fontSize: "0.72rem",
|
||
borderRadius: "12px",
|
||
border: "1px solid var(--weekly-border)",
|
||
background: already ? "var(--weekly-teal)" : "var(--weekly-bg)",
|
||
color: already ? "white" : "var(--weekly-text)",
|
||
cursor: already ? "default" : "pointer",
|
||
opacity: already ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{already ? "✓ " : "+ "}{label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<p style={{ fontSize: "0.72rem", color: "var(--weekly-text-light)", marginTop: "6px", fontStyle: "italic" }}>
|
||
{profile.language === "de"
|
||
? "DE/EN/ES: auch über Zitat-Service API verfügbar. FR/IT: kuratierte lokale Sammlung."
|
||
: "DE/EN/ES: also available via Zitat-Service API. FR/IT: curated local collection."}
|
||
</p>
|
||
</div>
|
||
</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) {
|
||
saveField("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) => saveFieldDebounced("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>
|
||
</div>
|
||
) : activeTab === "sync" ? (
|
||
<CalendarSyncRulesPanel
|
||
connections={connections}
|
||
t={t}
|
||
/>
|
||
) : activeTab === "projects" ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||
<div>
|
||
<h3 style={{ fontSize: "1rem", fontWeight: 600, margin: 0, display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<FolderOpen size={18} /> {t.projects || "Projects"}
|
||
</h3>
|
||
<p style={{ fontSize: "0.8rem", color: "var(--weekly-settings-label)", marginTop: "4px" }}>
|
||
{t.projectsDesc || "Organize tasks with color-coded projects"}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Existing projects list */}
|
||
{projects.length === 0 ? (
|
||
<div style={{ textAlign: "center", padding: "24px 12px", border: "1px dashed var(--weekly-border, #e5e7eb)", borderRadius: "10px" }}>
|
||
<ProjectIcon icon="folder" size={28} color="#aaa" />
|
||
<p style={{ fontSize: "0.85rem", color: "#aaa", fontStyle: "italic", marginTop: "8px" }}>
|
||
{t.noProjects || "No projects yet"}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
{projects.map((p) => (
|
||
<div key={p.id} style={{
|
||
display: "flex", alignItems: "center", gap: "10px",
|
||
padding: "10px 14px", borderRadius: "10px",
|
||
background: "var(--weekly-bg-soft, #f9fafb)",
|
||
borderLeft: `4px solid ${p.color || "#999"}`,
|
||
}}>
|
||
{editingProjectId === p.id ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<div style={{ position: "relative" }}>
|
||
<button onClick={() => setShowEditProjectIconPicker(!showEditProjectIconPicker)} style={{ width: "38px", height: "38px", borderRadius: "8px", border: "1px solid var(--weekly-border, #e5e7eb)", background: "var(--weekly-bg, #fff)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
<ProjectIcon icon={editProjectIcon} size={16} color="#555" />
|
||
</button>
|
||
{showEditProjectIconPicker && (
|
||
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50 }}>
|
||
<IconPicker selectedIcon={editProjectIcon} onSelect={(name) => { setEditProjectIcon(name); setShowEditProjectIconPicker(false); }} darkMode={false} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={editProjectName}
|
||
onChange={(e) => setEditProjectName(e.target.value)}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }}
|
||
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, icon: editProjectIcon }) })
|
||
.then(() => { onProjectsChanged(); setEditingProjectId(null); });
|
||
}
|
||
if (e.key === "Escape") setEditingProjectId(null);
|
||
}}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<input type="color" value={editProjectColor} onChange={(e) => setEditProjectColor(e.target.value)} style={{ width: "30px", height: "30px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }} />
|
||
<span style={{ fontSize: "0.8rem", color: "var(--weekly-settings-label)" }}>{profile.language === "de" ? "Farbe" : "Color"}</span>
|
||
<div style={{ flex: 1 }} />
|
||
<button onClick={() => setEditingProjectId(null)} style={{ padding: "6px 12px", fontSize: "0.8rem", background: "none", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "8px", cursor: "pointer" }}>
|
||
{profile.language === "de" ? "Abbrechen" : "Cancel"}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }) })
|
||
.then(() => { onProjectsChanged(); setEditingProjectId(null); });
|
||
}}
|
||
className="weekly-btn-primary"
|
||
style={{ padding: "6px 12px", fontSize: "0.8rem", display: "inline-flex", alignItems: "center", gap: "4px" }}
|
||
>
|
||
<Check size={14} /> {profile.language === "de" ? "Speichern" : "Save"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<ProjectIcon icon={p.icon} size={20} color={p.color || "#999"} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<span style={{ fontSize: "0.9rem", fontWeight: 600, display: "block" }}>{p.name}</span>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
setEditingProjectId(p.id);
|
||
setEditProjectName(p.name);
|
||
setEditProjectColor(p.color || "#3b82f6");
|
||
setEditProjectIcon(p.icon || "folder");
|
||
setShowEditProjectIconPicker(false);
|
||
}}
|
||
style={{ padding: "6px", opacity: 0.6, cursor: "pointer", background: "none", border: "none", borderRadius: "6px" }}
|
||
title={profile.language === "de" ? "Bearbeiten" : "Edit"}
|
||
>
|
||
<Pencil size={14} />
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const msg = profile.language === "de" ? `Projekt "${p.name}" löschen?` : `Delete project "${p.name}"?`;
|
||
if (confirm(msg)) {
|
||
fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged());
|
||
}
|
||
}}
|
||
style={{ padding: "6px", opacity: 0.6, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "6px" }}
|
||
title={profile.language === "de" ? "Löschen" : "Delete"}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Add new project form */}
|
||
<div style={{ borderTop: "1px solid var(--weekly-border, #e5e7eb)", paddingTop: "16px" }}>
|
||
<p style={{ fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", marginBottom: "10px" }}>
|
||
{profile.language === "de" ? "Projekt hinzufügen" : "Add Project"}
|
||
</p>
|
||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||
<div style={{ position: "relative" }}>
|
||
<button onClick={() => setShowNewProjectIconPicker(!showNewProjectIconPicker)} style={{ width: "38px", height: "38px", borderRadius: "8px", border: "1px solid var(--weekly-border, #e5e7eb)", background: "var(--weekly-bg, #fff)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
<ProjectIcon icon={newProjectIcon} size={16} color="#555" />
|
||
</button>
|
||
{showNewProjectIconPicker && (
|
||
<div style={{ position: "absolute", bottom: "100%", left: 0, marginBottom: "4px", zIndex: 50 }}>
|
||
<IconPicker selectedIcon={newProjectIcon} onSelect={(name) => { setNewProjectIcon(name); setShowNewProjectIconPicker(false); }} darkMode={false} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<input type="color" value={newProjectColor} onChange={(e) => setNewProjectColor(e.target.value)} style={{ width: "30px", height: "30px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }} />
|
||
<input
|
||
type="text"
|
||
value={newProjectName}
|
||
onChange={(e) => setNewProjectName(e.target.value)}
|
||
placeholder={profile.language === "de" ? "Name" : "Name"}
|
||
className="weekly-input"
|
||
style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }}
|
||
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, icon: newProjectIcon }) })
|
||
.then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("folder"); });
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
if (!newProjectName.trim()) return;
|
||
fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) })
|
||
.then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("folder"); });
|
||
}}
|
||
className="weekly-btn-primary"
|
||
style={{ padding: "8px 14px", fontSize: "0.85rem", display: "inline-flex", alignItems: "center", gap: "4px", whiteSpace: "nowrap" }}
|
||
>
|
||
<Plus size={14} /> {profile.language === "de" ? "Hinzufügen" : "Add"}
|
||
</button>
|
||
</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>
|
||
|
||
<button
|
||
onClick={() => { onRunSetupAssistant?.(); }}
|
||
style={{
|
||
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
|
||
width: "100%", padding: "12px 20px", borderRadius: "10px",
|
||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||
background: "var(--weekly-bg, #fff)",
|
||
color: "var(--weekly-text, #333)",
|
||
fontSize: "0.9rem", fontWeight: 500, cursor: "pointer",
|
||
transition: "all 0.15s",
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--weekly-hover, #f3f4f6)"; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.background = "var(--weekly-bg, #fff)"; }}
|
||
>
|
||
<Play size={16} />
|
||
{t.setupAssistant}
|
||
</button>
|
||
</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) => saveFieldDebounced("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) => saveField("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)",
|
||
}}
|
||
>
|
||
<h3
|
||
style={{
|
||
marginBottom: "10px",
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-title)",
|
||
}}
|
||
>
|
||
{profile.language === "de" ? "Datenexport" : "Data Export"}
|
||
</h3>
|
||
<p style={{ fontSize: "0.85rem", color: "var(--weekly-settings-label)", marginBottom: "14px" }}>
|
||
{profile.language === "de"
|
||
? "CSV-Arbeitsbericht erledigter Aufgaben — nach Kalenderwochen gruppiert. Wähle Felder, Zeitraum und lade die Datei herunter."
|
||
: "CSV work report of completed tasks grouped by calendar week. Choose fields, date range and download."}
|
||
</p>
|
||
|
||
{/* Date range */}
|
||
<div style={{ display: "flex", gap: "10px", marginBottom: "14px" }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: "block", fontSize: "0.78rem", fontWeight: 600, marginBottom: "4px", color: "var(--weekly-settings-label)" }}>
|
||
{profile.language === "de" ? "Von" : "From"}
|
||
</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.78rem", fontWeight: 600, marginBottom: "4px", color: "var(--weekly-settings-label)" }}>
|
||
{profile.language === "de" ? "Bis" : "To"}
|
||
</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>
|
||
|
||
{/* Quick date presets */}
|
||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginBottom: "16px" }}>
|
||
{[
|
||
{ label: profile.language === "de" ? "Diese Woche" : "This week", days: 7 },
|
||
{ label: profile.language === "de" ? "Dieser Monat" : "This month", days: 30 },
|
||
{ label: profile.language === "de" ? "Letzter Monat" : "Last month", days: 60, offset: 30 },
|
||
{ label: profile.language === "de" ? "Dieses Jahr" : "This year", days: 365 },
|
||
].map(({ label, days, offset }) => (
|
||
<button
|
||
key={label}
|
||
onClick={() => {
|
||
const end = new Date();
|
||
if (offset) end.setDate(end.getDate() - offset);
|
||
const start = new Date(end);
|
||
start.setDate(start.getDate() - days + (offset ? 0 : 0));
|
||
// Simpler: this month = first day of month
|
||
if (label.includes("Monat") || label.includes("month")) {
|
||
if (offset) {
|
||
// last month
|
||
const now = new Date();
|
||
const s = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||
const e = new Date(now.getFullYear(), now.getMonth(), 0);
|
||
setExportStartDate(s.toISOString().split("T")[0]);
|
||
setExportEndDate(e.toISOString().split("T")[0]);
|
||
return;
|
||
} else {
|
||
const now = new Date();
|
||
const s = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
setExportStartDate(s.toISOString().split("T")[0]);
|
||
setExportEndDate(new Date().toISOString().split("T")[0]);
|
||
return;
|
||
}
|
||
}
|
||
if (label.includes("Jahr") || label.includes("year")) {
|
||
const now = new Date();
|
||
setExportStartDate(`${now.getFullYear()}-01-01`);
|
||
setExportEndDate(now.toISOString().split("T")[0]);
|
||
return;
|
||
}
|
||
// This week
|
||
const now = new Date();
|
||
const day = now.getDay() || 7;
|
||
const mon = new Date(now); mon.setDate(now.getDate() - day + 1);
|
||
setExportStartDate(mon.toISOString().split("T")[0]);
|
||
setExportEndDate(now.toISOString().split("T")[0]);
|
||
}}
|
||
style={{ fontSize: "0.75rem", padding: "3px 9px", borderRadius: "5px", border: "1px solid var(--weekly-settings-input-border)", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-label)", cursor: "pointer" }}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Field picker */}
|
||
<div style={{ marginBottom: "14px" }}>
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" }}>
|
||
<span style={{ fontSize: "0.78rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
|
||
{profile.language === "de" ? "Spalten auswählen:" : "Select columns:"}
|
||
</span>
|
||
<div style={{ display: "flex", gap: "6px" }}>
|
||
<button
|
||
onClick={() => setExportFields(new Set(EXPORT_FIELDS.map(f => f.key)))}
|
||
style={{ fontSize: "0.72rem", padding: "2px 7px", borderRadius: "4px", border: "1px solid var(--weekly-settings-input-border)", background: "transparent", color: "var(--weekly-settings-label)", cursor: "pointer" }}
|
||
>
|
||
{profile.language === "de" ? "Alle" : "All"}
|
||
</button>
|
||
<button
|
||
onClick={() => setExportFields(new Set(EXPORT_FIELDS.filter(f => f.defaultOn).map(f => f.key)))}
|
||
style={{ fontSize: "0.72rem", padding: "2px 7px", borderRadius: "4px", border: "1px solid var(--weekly-settings-input-border)", background: "transparent", color: "var(--weekly-settings-label)", cursor: "pointer" }}
|
||
>
|
||
{profile.language === "de" ? "Standard" : "Default"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "5px 12px" }}>
|
||
{EXPORT_FIELDS.map(f => {
|
||
const checked = exportFields.has(f.key);
|
||
const label = profile.language === "de" ? f.labelDe : f.labelEn;
|
||
return (
|
||
<label
|
||
key={f.key}
|
||
style={{ display: "flex", alignItems: "center", gap: "7px", fontSize: "0.82rem", color: "var(--weekly-settings-text)", cursor: "pointer", padding: "3px 0" }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={() => {
|
||
setExportFields(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(f.key)) next.delete(f.key);
|
||
else next.add(f.key);
|
||
return next;
|
||
});
|
||
}}
|
||
style={{ accentColor: "#6366f1", width: "14px", height: "14px", flexShrink: 0 }}
|
||
/>
|
||
{label}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Download buttons */}
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
<a
|
||
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}&lang=${profile.language || 'de'}&fields=${Array.from(exportFields).join(',')}`}
|
||
target="_blank"
|
||
style={{ flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px", textDecoration: "none", background: "var(--weekly-settings-item-bg)", border: "1px solid var(--weekly-settings-input-border)", color: "var(--weekly-settings-text)", padding: "9px 12px", borderRadius: "4px", fontWeight: 500, fontSize: "0.85rem", transition: "background-color 0.2s" }}
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
||
CSV
|
||
</a>
|
||
<a
|
||
href={`/api/user/export-pdf?startDate=${exportStartDate}&endDate=${exportEndDate}&lang=${profile.language || 'de'}&fields=${Array.from(exportFields).join(',')}`}
|
||
target="_blank"
|
||
style={{ flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px", textDecoration: "none", background: "#6366f1", border: "none", color: "#fff", padding: "9px 12px", borderRadius: "4px", fontWeight: 600, fontSize: "0.85rem", transition: "background-color 0.2s" }}
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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"/><rect x="8" y="13" width="8" height="6" rx="1"/></svg>
|
||
PDF
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Backup & Restore Section */}
|
||
<div
|
||
style={{
|
||
marginTop: "20px",
|
||
paddingTop: "20px",
|
||
borderTop: "1px solid var(--weekly-border)",
|
||
}}
|
||
>
|
||
<h3
|
||
style={{
|
||
marginBottom: "10px",
|
||
fontSize: "1rem",
|
||
fontWeight: 600,
|
||
color: "var(--weekly-settings-title)",
|
||
}}
|
||
>
|
||
{t.backupRestore}
|
||
</h3>
|
||
<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>
|
||
|
||
<div className="bg-amber-50 border border-amber-200 rounded p-3 mb-4 text-sm text-amber-800" style={{ display: "flex", gap: "8px", alignItems: "flex-start" }}>
|
||
<span style={{ fontSize: "1rem", flexShrink: 0 }}>⚠️</span>
|
||
<p style={{ margin: 0, fontSize: "0.8rem", lineHeight: 1.4 }}>
|
||
{t.appleRemindersNote}
|
||
</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" aria-hidden="true"></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" aria-hidden="true"></div>
|
||
Connecting...
|
||
</>
|
||
) : (
|
||
"Connect"
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default SettingsSidebar;
|