Files
kadence/frontend/src/pages/SettingsPage.tsx
T
timka123 77b26757bf refactor: enhance TimerPage layout and functionality; update theme and tokens for improved design consistency
- Refactored TimerPage component to improve layout and user experience.
- Updated theme configuration to utilize design tokens for better maintainability.
- Introduced new tokens for colors and typography to align with design principles.
- Simplified state management and improved timer functionality.
- Enhanced accessibility and responsiveness of UI elements.
2026-10-18 23:57:36 +03:00

606 lines
20 KiB
TypeScript

/**
* Every setting, grouped the way you think about them.
*
* Secrets come back masked (`••••••••`) and are only sent when you actually type
* a new one, so saving this form never leaks — or clobbers — a token.
*/
import { useEffect, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
FormControlLabel,
Grid,
MenuItem,
Slider,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
import { api, getToken, setToken } from "../lib/api";
import { useSettings } from "../lib/settings";
import type { AppSettings } from "../lib/types";
type Patch = Record<string, unknown>;
export default function SettingsPage({ onToast }: { onToast: (message: string) => void }) {
const { settings, reload, save } = useSettings();
const [token, setLocalToken] = useState(getToken());
const [testing, setTesting] = useState<string | null>(null);
const patch = async (change: Patch, message = "Saved") => {
try {
await save(change);
onToast(message);
} catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc));
}
};
if (!settings) return <Typography>Loading…</Typography>;
const testSource = async (name: string) => {
setTesting(name);
try {
const result = await api.post<{ ok: boolean; detail: string }>(`/api/tasks/sources/${name}/test`);
onToast(`${name}: ${result.ok ? "✓ " : "✗ "}${result.detail}`);
} catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc));
} finally {
setTesting(null);
}
};
return (
<>
<PageHeader
eyebrow="Shared with the desktop app and the Telegram bot"
title="Settings"
caption="Secrets are masked once stored — leave the dots alone to keep the saved value."
/>
<Stack spacing={5}>
<Section title="Timer" subtitle="Shared with the desktop app and the Telegram bot.">
<Grid container spacing={2}>
{(
[
["work_minutes", "Focus (min)", 1, 240],
["short_break_minutes", "Short break", 1, 120],
["long_break_minutes", "Long break", 1, 240],
["cycles_before_long_break", "Long break every", 1, 12],
["daily_goal_pomodoros", "Daily goal", 1, 40],
] as const
).map(([key, label, min, max]) => (
<Grid item xs={6} md={2.4} key={key}>
<NumberField
label={label}
value={settings.pomodoro[key] as number}
min={min}
max={max}
onCommit={(value) => void patch({ pomodoro: { [key]: value } })}
/>
</Grid>
))}
</Grid>
<Stack direction="row" sx={{ flexWrap: "wrap", mt: 1 }}>
{(
[
["timer_enabled", "Timer enabled (off = view only)"],
["auto_start_breaks", "Auto-start breaks"],
["auto_start_work", "Auto-start next focus"],
["allow_pause", "Allow pausing"],
["strict_mode", "Strict mode (a reason to abandon)"],
["count_interruptions", "Count interruptions"],
["alarm_sound", "Alarm sound"],
["desktop_notifications", "Browser notifications"],
] as const
).map(([key, label]) => (
<FormControlLabel
key={key}
control={
<Switch
checked={settings.pomodoro[key] as boolean}
onChange={(event) => void patch({ pomodoro: { [key]: event.target.checked } })}
/>
}
label={label}
/>
))}
</Stack>
<Box sx={{ maxWidth: 320, mt: 1 }}>
<Typography variant="caption" color="text.secondary">
Alarm volume
</Typography>
<Slider
value={settings.pomodoro.alarm_volume}
min={0}
max={100}
valueLabelDisplay="auto"
onChangeCommitted={(_event, value) =>
void patch({ pomodoro: { alarm_volume: value as number } })
}
/>
</Box>
</Section>
<Section title="Task sources" subtitle="The same credentials jira-helper uses.">
<SourceBlock
name="jira"
label="Jira"
enabled={settings.sources.jira.enabled}
onToggle={(value) => void patch({ sources: { jira: { enabled: value } } })}
onTest={() => void testSource("jira")}
testing={testing === "jira"}
>
<Field
label="Site URL"
value={settings.sources.jira.base_url}
placeholder="https://your-site.atlassian.net"
onCommit={(value) => void patch({ sources: { jira: { base_url: value } } })}
/>
<Field
label="Account email"
value={settings.sources.jira.email}
onCommit={(value) => void patch({ sources: { jira: { email: value } } })}
/>
<Field
label="API token"
value={settings.sources.jira.api_token}
secret
onCommit={(value) => void patch({ sources: { jira: { api_token: value } } })}
/>
<Field
label="Extra JQL"
value={settings.sources.jira.extra_jql}
placeholder="assignee = currentUser()"
onCommit={(value) => void patch({ sources: { jira: { extra_jql: value } } })}
/>
<Field
label="Start-date field"
value={settings.sources.jira.start_date_field}
onCommit={(value) => void patch({ sources: { jira: { start_date_field: value } } })}
/>
</SourceBlock>
<SourceBlock
name="todoist"
label="Todoist"
enabled={settings.sources.todoist.enabled}
onToggle={(value) => void patch({ sources: { todoist: { enabled: value } } })}
onTest={() => void testSource("todoist")}
testing={testing === "todoist"}
>
<Field
label="API token"
value={settings.sources.todoist.api_token}
secret
onCommit={(value) => void patch({ sources: { todoist: { api_token: value } } })}
/>
<Field
label="Projects (comma separated, blank = all)"
value={settings.sources.todoist.projects.join(", ")}
onCommit={(value) =>
void patch({
sources: {
todoist: { projects: value.split(",").map((item) => item.trim()).filter(Boolean) },
},
})
}
/>
<Field
label="Filter query (blank = every open task)"
value={settings.sources.todoist.filter_query}
placeholder="overdue | today"
onCommit={(value) => void patch({ sources: { todoist: { filter_query: value } } })}
/>
</SourceBlock>
<SourceBlock
name="google_tasks"
label="Google Tasks"
enabled={settings.sources.google_tasks.enabled}
onToggle={(value) => void patch({ sources: { google_tasks: { enabled: value } } })}
onTest={() => void testSource("google_tasks")}
testing={testing === "google_tasks"}
>
<Field
label="Client id"
value={settings.sources.google_tasks.client_id}
onCommit={(value) => void patch({ sources: { google_tasks: { client_id: value } } })}
/>
<Field
label="Client secret"
value={settings.sources.google_tasks.client_secret}
secret
onCommit={(value) => void patch({ sources: { google_tasks: { client_secret: value } } })}
/>
<Field
label="Refresh token"
value={settings.sources.google_tasks.refresh_token}
secret
onCommit={(value) => void patch({ sources: { google_tasks: { refresh_token: value } } })}
/>
</SourceBlock>
<SourceBlock
name="laundry"
label="Laundry bookings"
enabled={settings.sources.laundry.enabled}
onToggle={(value) => void patch({ sources: { laundry: { enabled: value } } })}
onTest={() => void testSource("laundry")}
testing={testing === "laundry"}
>
<Field
label="Site"
value={settings.sources.laundry.base_url}
onCommit={(value) => void patch({ sources: { laundry: { base_url: value } } })}
/>
<Field
label="sessionid cookie"
value={settings.sources.laundry.session_cookie}
secret
onCommit={(value) => void patch({ sources: { laundry: { session_cookie: value } } })}
/>
</SourceBlock>
<NumberField
label="Refresh every (minutes)"
value={settings.sources.refresh_interval_minutes}
min={1}
max={1440}
onCommit={(value) => void patch({ sources: { refresh_interval_minutes: value } })}
/>
</Section>
<Section
title="Telegram"
subtitle="Daily report, morning briefing, nudges — and /today, /report commands in the chat."
>
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", rowGap: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.notifications.telegram.enabled}
onChange={(event) =>
void patch({ notifications: { telegram: { enabled: event.target.checked } } })
}
/>
}
label="Enabled"
/>
<Field
label="Bot token"
value={settings.notifications.telegram.bot_token}
secret
onCommit={(value) => void patch({ notifications: { telegram: { bot_token: value } } })}
/>
<Field
label="Chat id"
value={settings.notifications.telegram.chat_id}
onCommit={(value) => void patch({ notifications: { telegram: { chat_id: value } } })}
/>
<Button
onClick={async () => {
const result = await api.post<{ deliveries: { channel: string; ok: boolean; detail: string }[] }>(
"/api/notifications/test",
{},
);
onToast(result.deliveries.map((d) => `${d.channel}: ${d.ok ? "ok" : d.detail}`).join(" · "));
}}
>
Send a test message
</Button>
</Stack>
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={6} md={3}>
<TimeField
label="Daily report at"
value={settings.notifications.telegram.daily_report_time}
onCommit={(value) =>
void patch({ notifications: { telegram: { daily_report_time: value } } })
}
/>
</Grid>
<Grid item xs={6} md={3}>
<TimeField
label="Morning briefing at"
value={settings.notifications.telegram.morning_briefing_time}
onCommit={(value) =>
void patch({ notifications: { telegram: { morning_briefing_time: value } } })
}
/>
</Grid>
<Grid item xs={6} md={3}>
<NumberField
label="Nudge after idle (min)"
value={settings.notifications.telegram.nudge_after_idle_minutes}
min={10}
max={600}
onCommit={(value) =>
void patch({ notifications: { telegram: { nudge_after_idle_minutes: value } } })
}
/>
</Grid>
<Grid item xs={6} md={3}>
<NumberField
label="Cheer every N pomodoros"
value={settings.notifications.telegram.celebrate_every_n_pomodoros}
min={0}
max={50}
onCommit={(value) =>
void patch({ notifications: { telegram: { celebrate_every_n_pomodoros: value } } })
}
/>
</Grid>
</Grid>
<Stack direction="row" sx={{ flexWrap: "wrap", mt: 1 }}>
{(
[
["daily_report", "Daily report"],
["morning_briefing", "Morning briefing"],
["weekly_report", "Weekly review"],
["nudge_enabled", "Nudge me when idle"],
["commands_enabled", "Chat commands"],
["goal_warning", "Warn when the goal slips away"],
] as const
).map(([key, label]) => (
<FormControlLabel
key={key}
control={
<Switch
checked={settings.notifications.telegram[key] as boolean}
onChange={(event) =>
void patch({ notifications: { telegram: { [key]: event.target.checked } } })
}
/>
}
label={label}
/>
))}
</Stack>
<Stack direction="row" spacing={2} sx={{ mt: 1, flexWrap: "wrap", rowGap: 2 }}>
<TextField
select
label="Tone"
sx={{ minWidth: 200 }}
value={settings.notifications.report.tone}
onChange={(event) =>
void patch({ notifications: { report: { tone: event.target.value } } })
}
>
<MenuItem value="friendly">Friendly</MenuItem>
<MenuItem value="coach">Coach</MenuItem>
<MenuItem value="drill_sergeant">Drill sergeant</MenuItem>
<MenuItem value="dry">Dry</MenuItem>
</TextField>
<TextField
select
label="Language"
sx={{ minWidth: 160 }}
value={settings.notifications.report.language}
onChange={(event) =>
void patch({ notifications: { report: { language: event.target.value } } })
}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
</TextField>
<FormControlLabel
control={
<Switch
checked={settings.notifications.report.include_funny_stats}
onChange={(event) =>
void patch({ notifications: { report: { include_funny_stats: event.target.checked } } })
}
/>
}
label="Funny stats"
/>
<FormControlLabel
control={
<Switch
checked={settings.notifications.telegram.quiet_hours.enabled}
onChange={(event) =>
void patch({
notifications: { telegram: { quiet_hours: { enabled: event.target.checked } } },
})
}
/>
}
label="Quiet hours"
/>
</Stack>
</Section>
<Section title="General" subtitle="Timezone, theme, and this browser's API token.">
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", rowGap: 2 }}>
<Field
label="Timezone"
value={settings.timezone}
onCommit={(value) => void patch({ timezone: value })}
/>
<TextField
select
label="Theme"
sx={{ minWidth: 160 }}
value={settings.appearance.theme}
onChange={(event) => void patch({ appearance: { theme: event.target.value } })}
>
<MenuItem value="system">Follow the system</MenuItem>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</TextField>
<TextField
label="API token (this browser)"
type="password"
value={token}
onChange={(event) => setLocalToken(event.target.value)}
onBlur={() => {
setToken(token);
void reload();
onToast("Token stored in this browser");
}}
/>
</Stack>
<Alert severity="info" variant="outlined" sx={{ mt: 2 }}>
Website and application blocking is enforced by the native desktop app — install it from{" "}
<code>desktop/</code>; its lists live on the <strong>Blocker</strong> page.
</Alert>
</Section>
</Stack>
</>
);
}
function Section({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<Box>
<SectionTitle>{title}</SectionTitle>
{subtitle && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{subtitle}
</Typography>
)}
<Stack spacing={2}>{children}</Stack>
</Box>
);
}
function SourceBlock({
label,
enabled,
onToggle,
onTest,
testing,
children,
}: {
name: string;
label: string;
enabled: boolean;
onToggle: (value: boolean) => void;
onTest: () => void;
testing: boolean;
children: React.ReactNode;
}) {
return (
<Panel>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: enabled ? 1.5 : 0 }}>
<Typography sx={{ fontWeight: 600, flexGrow: 1 }}>{label}</Typography>
{enabled && <Chip size="small" color="success" variant="outlined" label="on" />}
<Button size="small" disabled={!enabled || testing} onClick={onTest}>
{testing ? "Testing…" : "Test connection"}
</Button>
<Switch checked={enabled} onChange={(event) => onToggle(event.target.checked)} />
</Stack>
{enabled && (
<Stack spacing={1.5} direction="row" sx={{ flexWrap: "wrap", rowGap: 1.5 }}>
{children}
</Stack>
)}
</Panel>
);
}
/** A text field that only reports a change when you leave it (one save per edit). */
function Field({
label,
value,
placeholder,
secret,
onCommit,
}: {
label: string;
value: string;
placeholder?: string;
secret?: boolean;
onCommit: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
return (
<TextField
label={label}
value={draft}
placeholder={placeholder}
type={secret ? "password" : "text"}
sx={{ minWidth: 260 }}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => draft !== value && onCommit(draft)}
onKeyDown={(event) => {
if (event.key === "Enter" && draft !== value) onCommit(draft);
}}
/>
);
}
function NumberField({
label,
value,
min,
max,
onCommit,
}: {
label: string;
value: number;
min: number;
max: number;
onCommit: (value: number) => void;
}) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
const commit = () => {
const parsed = Number(draft);
if (Number.isFinite(parsed) && parsed >= min && parsed <= max && parsed !== value) onCommit(parsed);
else setDraft(String(value));
};
return (
<TextField
label={label}
type="number"
value={draft}
fullWidth
inputProps={{ min, max }}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => event.key === "Enter" && commit()}
/>
);
}
function TimeField({
label,
value,
onCommit,
}: {
label: string;
value: string;
onCommit: (value: string) => void;
}) {
return (
<TextField
label={label}
type="time"
fullWidth
InputLabelProps={{ shrink: true }}
value={(value || "00:00").slice(0, 5)}
onChange={(event) => onCommit(`${event.target.value}:00`)}
/>
);
}
export type { AppSettings };