Today Highlight, Past Days, Saturday and Sunday now also appear as their own groups in the CSS Variables panel, bound to the same profile fields as the existing Element/Weekend Colors controls so both stay in sync. Every row now shows the color swatch before its label. v1.113.1
5610 lines
361 KiB
TypeScript
5610 lines
361 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. Entries with
|
||
// a `profileKey` instead read/write that scalar profile field directly (same fields the
|
||
// "Element Colors" / "Weekend Highlight Colors" controls elsewhere in this tab use), so both
|
||
// controls for the same setting always stay in sync.
|
||
const CSS_VARIABLE_GROUPS: { title: string; vars: { name: string; label: string; default: string; profileKey?: string }[] }[] = [
|
||
{
|
||
title: "Element Colors",
|
||
vars: [
|
||
{ name: "todayHighlightColor", label: "Today Highlight", default: "#f0fafa", profileKey: "todayHighlightColor" },
|
||
{ name: "pastDayColor", label: "Past Days", default: "#a6a6a7", profileKey: "pastDayColor" },
|
||
],
|
||
},
|
||
{
|
||
title: "Weekend Highlight Colors",
|
||
vars: [
|
||
{ name: "weekendColorSat", label: "Saturday", default: "#666666", profileKey: "weekendColorSat" },
|
||
{ name: "weekendColorSun", label: "Sunday", default: "#dc2626", profileKey: "weekendColorSun" },
|
||
],
|
||
},
|
||
{
|
||
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 fieldDebounceTimers = useRef<Record<string, NodeJS.Timeout>>({});
|
||
|
||
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 setProfileField = (key: string, value: string) => {
|
||
setProfile((p: any) => ({ ...p, [key]: value }));
|
||
if (fieldDebounceTimers.current[key]) clearTimeout(fieldDebounceTimers.current[key]);
|
||
fieldDebounceTimers.current[key] = setTimeout(() => saveSetting(key, value), 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) => {
|
||
const value = v.profileKey ? (profile[v.profileKey] || v.default) : (vars[v.name] || v.default);
|
||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||
v.profileKey ? setProfileField(v.profileKey, e.target.value) : setVar(v.name, e.target.value);
|
||
return (
|
||
<div
|
||
key={v.name}
|
||
title={v.name}
|
||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||
>
|
||
<input
|
||
type="color"
|
||
value={value}
|
||
onChange={onChange}
|
||
style={{
|
||
width: "32px",
|
||
height: "22px",
|
||
cursor: "pointer",
|
||
border: "1px solid var(--weekly-settings-input-border)",
|
||
borderRadius: "4px",
|
||
background: "transparent",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
fontSize: "0.72rem",
|
||
color: "var(--weekly-settings-label)",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{v.label}
|
||
</span>
|
||
</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;
|