Refine settings, archive, and check-in styling
This commit is contained in:
@@ -126,7 +126,7 @@ describe("homepage", () => {
|
|||||||
await f.request(`/habits/${h.id}`, "DELETE");
|
await f.request(`/habits/${h.id}`, "DELETE");
|
||||||
await render("/");
|
await render("/");
|
||||||
await act(async () => buttonNamed("Archived habits").click());
|
await act(async () => buttonNamed("Archived habits").click());
|
||||||
expect(container.textContent).toContain("Restore Reading");
|
expect(buttonNamed("Restore Reading")).toBeDefined();
|
||||||
await act(async () => buttonNamed("View history").click());
|
await act(async () => buttonNamed("View history").click());
|
||||||
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
||||||
await act(async () => buttonNamed("Restore Reading").click());
|
await act(async () => buttonNamed("Restore Reading").click());
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useId, useRef, useState } from "react";
|
|||||||
import type { PublicUser } from "../shared/user";
|
import type { PublicUser } from "../shared/user";
|
||||||
import { habitRequest } from "../lib/dashboard";
|
import { habitRequest } from "../lib/dashboard";
|
||||||
import { Button, ButtonLink } from "./design-system/primitives";
|
import { Button, ButtonLink } from "./design-system/primitives";
|
||||||
import { Card } from "./design-system/Card";
|
import { Download, Trash2 } from "lucide-react";
|
||||||
|
import { FormMessage, SettingRow, WorkspacePanel } from "./design-system/WorkspacePanel";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
|
|
||||||
export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser; onChanged: () => void; onClose: () => void }) {
|
export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser; onChanged: () => void; onClose: () => void }) {
|
||||||
@@ -21,28 +22,29 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
|
|||||||
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
||||||
finally { saving.current = false; setBusy(false); }
|
finally { saving.current = false; setBusy(false); }
|
||||||
}
|
}
|
||||||
return <section className="ds-section" id="account-settings" aria-label="Account settings">
|
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={busy ? undefined : onClose} closeLabel="Close settings">
|
||||||
<Card heading="Account settings" headingLevel={2}>
|
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
|
||||||
<form onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
|
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
|
||||||
<Field label="Timezone" hint="Daily check-ins follow this timezone. Earlier deadlines stay as recorded.">
|
<Field label="Timezone">
|
||||||
{(id, hintId) => <input id={id} aria-describedby={hintId} list={listId} value={timezone} required maxLength={100} disabled={busy} onChange={event => setTimezone(event.target.value)} />}
|
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy} onChange={event => setTimezone(event.target.value)} />}
|
||||||
</Field>
|
</Field>
|
||||||
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
|
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
|
||||||
<Button type="submit" disabled={busy}>Save timezone</Button>
|
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy}>{busy && !deleting ? "Saving…" : "Save timezone"}</Button></div>
|
||||||
</form>
|
</form>
|
||||||
<ReminderSettings timezone={user.timezone} />
|
</SettingRow>
|
||||||
<div className="ds-actions">
|
<ReminderSettings timezone={user.timezone} />
|
||||||
<ButtonLink href="/api/account/export" download="minabot-export.json">Export my data</ButtonLink>
|
<SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}>
|
||||||
<Button variant="text" disabled={busy} onClick={() => setDeleting(!deleting)} aria-expanded={deleting}>Delete account</Button>
|
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
|
||||||
<Button variant="text" disabled={busy} onClick={onClose}>Close settings</Button>
|
</SettingRow>
|
||||||
</div>
|
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
|
||||||
<p className="type-small">Export includes your profile, habits, history, and sharing records. Sessions and credentials are excluded.</p>
|
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :
|
||||||
{deleting && <form onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}>
|
<form className="ds-form-stack ds-confirmation" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}>
|
||||||
<p>This permanently deletes your account and habit history and signs out every device. Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operator’s retention policy.</p>
|
<p className="type-ui-heading">This cannot be undone.</p>
|
||||||
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field>
|
<p className="ds-supporting-copy">Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operator’s retention policy.</p>
|
||||||
<div className="ds-actions"><Button type="submit" disabled={busy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
|
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field>
|
||||||
</form>}
|
<div className="ds-action-row"><Button type="submit" disabled={busy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
|
||||||
{error && <p role="alert">{error}</p>}
|
</form>}
|
||||||
</Card>
|
</SettingRow>
|
||||||
</section>;
|
<FormMessage error>{error}</FormMessage>
|
||||||
|
</WorkspacePanel>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { habitRequest, type TodayHabit } from "../lib/dashboard";
|
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||||
import { Button, SectionHeading } from "./design-system/primitives";
|
import { Button } from "./design-system/primitives";
|
||||||
import { Card } from "./design-system/Card";
|
import { Archive, ChevronDown, RotateCcw } from "lucide-react";
|
||||||
|
import { FormMessage, WorkspacePanel } from "./design-system/WorkspacePanel";
|
||||||
import { HabitHistory } from "./HabitHistory";
|
import { HabitHistory } from "./HabitHistory";
|
||||||
|
|
||||||
export function ArchivedHabits({ date, revision, onChanged, onExpired }: {
|
export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }: {
|
||||||
date: string; revision: number; onChanged: () => void; onExpired: () => void;
|
date: string; revision: number; onChanged: () => void; onExpired: () => void; onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [habits, setHabits] = useState<TodayHabit[]>([]);
|
const [habits, setHabits] = useState<TodayHabit[]>([]);
|
||||||
const [selected, setSelected] = useState<string>();
|
const [selected, setSelected] = useState<string>();
|
||||||
@@ -36,18 +37,22 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired }: {
|
|||||||
setError(message); if (message.includes("session has expired")) expired.current();
|
setError(message); if (message.includes("session has expired")) expired.current();
|
||||||
} finally { saving.current = false; setBusy(false); }
|
} finally { saving.current = false; setBusy(false); }
|
||||||
}
|
}
|
||||||
return <section className="ds-section" id="archived-habits" aria-label="Archived habits">
|
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever you’re ready." onClose={busy ? undefined : onClose} closeLabel="Close archive">
|
||||||
<SectionHeading number="YOUR ARCHIVE" title="Archived habits" />
|
<FormMessage error>{error}</FormMessage>
|
||||||
<p>Restore a habit to resume its schedule today. Earlier history stays available.</p>
|
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
|
||||||
{error && <p role="alert">{error} <Button onClick={() => setAttempt(value => value + 1)}>Retry archive</Button></p>}
|
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}
|
||||||
{loading ? <p role="status">Loading archive…</p> : !habits.length && <p>No archived habits.</p>}
|
<FormMessage>{notice}</FormMessage>
|
||||||
<p role="status">{notice}</p>
|
<div className="ds-record-list">
|
||||||
{habits.map(habit => <Card key={habit.habitId} heading={habit.name ?? "Habit"}>
|
{habits.map(habit => <article key={habit.habitId} className="ds-record" aria-labelledby={`archive-title-${habit.habitId}`}>
|
||||||
<div className="ds-actions">
|
<header className="ds-record-heading">
|
||||||
<Button variant="secondary" disabled={busy || loading} onClick={() => void restore(habit)}>Restore {habit.name}</Button>
|
<div className="ds-record-copy"><h3 id={`archive-title-${habit.habitId}`}>{habit.name ?? "Habit"}</h3><p>{scheduleLabel(habit.requirements?.schedule)} · Archived</p></div>
|
||||||
<Button variant="text" onClick={() => setSelected(selected === habit.habitId ? undefined : habit.habitId)} aria-expanded={selected === habit.habitId}>{selected === habit.habitId ? "Hide history" : "View history"}</Button>
|
<div className="ds-action-row">
|
||||||
</div>
|
<Button variant="text" onClick={() => setSelected(selected === habit.habitId ? undefined : habit.habitId)} aria-expanded={selected === habit.habitId} aria-controls={selected === habit.habitId ? `archive-history-${habit.habitId}` : undefined}>{selected === habit.habitId ? "Hide history" : "View history"}<ChevronDown className="ds-disclosure-icon" size={16} aria-hidden="true" /></Button>
|
||||||
{selected === habit.habitId && <HabitHistory habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} />}
|
<Button variant="secondary" disabled={busy || loading} aria-label={`Restore ${habit.name}`} onClick={() => void restore(habit)}><RotateCcw size={16} aria-hidden="true" />Restore</Button>
|
||||||
</Card>)}
|
</div>
|
||||||
</section>;
|
</header>
|
||||||
|
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||||
|
</article>)}
|
||||||
|
</div>
|
||||||
|
</WorkspacePanel>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { FormMessage } from "./design-system/WorkspacePanel";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { habitRequest, type TodayHabit } from "../lib/dashboard";
|
import { habitRequest, formatTrackingDate, type TodayHabit } from "../lib/dashboard";
|
||||||
import { Button, Checkbox } from "./design-system/primitives";
|
import { Button, Checkbox } from "./design-system/primitives";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
|
|
||||||
@@ -38,23 +39,23 @@ export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
|
|||||||
setError(message); if (message.includes("session has expired")) expired.current?.();
|
setError(message); if (message.includes("session has expired")) expired.current?.();
|
||||||
} finally { saving.current = false; setBusy(false); }
|
} finally { saving.current = false; setBusy(false); }
|
||||||
}
|
}
|
||||||
return <div className="ds-inline-item-form" aria-label={`Check-in for ${date}`}>
|
return <div className="ds-form-stack ds-check-in-controls" aria-label={`Check-in for ${date}`}>
|
||||||
<p className="type-ui-heading">Check-in · {date}</p>
|
<header className="ds-check-in-heading"><p className="ds-eyebrow">EDITING CHECK-IN</p><p className="type-ui-heading">{formatTrackingDate(date)}</p></header>
|
||||||
{error && <p role="alert">{error}</p>}
|
<FormMessage error>{error}</FormMessage>
|
||||||
{!day ? error ? <Button onClick={() => setAttempt(value => value + 1)}>Retry check-in</Button> : <p role="status">Loading check-in…</p>
|
{!day ? error ? <Button onClick={() => setAttempt(value => value + 1)}>Retry check-in</Button> : <p role="status">Loading check-in…</p>
|
||||||
: day.future ? <p>Future check-ins cannot be edited.</p>
|
: day.future ? <p>Future check-ins cannot be edited.</p>
|
||||||
: !day.due ? <p>Nothing was scheduled for this date.</p>
|
: !day.due ? <p>Nothing was scheduled for this date.</p>
|
||||||
: <>
|
: <>
|
||||||
<p className="type-small">{day.name} · {day.timezone}. Changes use the requirements saved for this date.</p>
|
<p className="ds-supporting-copy">{day.timezone} · Using this day’s saved target and schedule.</p>
|
||||||
{day.method === "count" ? <form onSubmit={event => { event.preventDefault(); void save({ count: Number(count) }); }}>
|
{day.method === "count" ? <form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void save({ count: Number(count) }); }}>
|
||||||
<Field label={`Count (${day.unit}, target ${day.target})`}>
|
<Field label={`Count (${day.unit}, target ${day.target})`}>
|
||||||
{id => <input id={id} type="number" min={0} max={1000000000} step={1} required value={count} disabled={busy || disabled} onChange={event => setCount(event.target.value)} />}
|
{id => <input id={id} type="number" min={0} max={1000000000} step={1} required value={count} disabled={busy || disabled} onChange={event => setCount(event.target.value)} />}
|
||||||
</Field>
|
</Field>
|
||||||
{day.carriedFrom && <p className="type-small">Includes carryover from {day.carriedFrom}. Saving sets this day's total.</p>}
|
{day.carriedFrom && <p className="type-small">Includes carryover from {day.carriedFrom}. Saving sets this day's total.</p>}
|
||||||
<Button type="submit" disabled={busy || disabled}>{busy ? "Saving…" : "Save check-in"}</Button>
|
<div className="ds-action-row"><Button type="submit" disabled={busy || disabled}>{busy ? "Saving…" : "Save check-in"}</Button></div>
|
||||||
</form> : day.method === "manual" ? <Checkbox label="Completed on this date" checked={day.complete} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked })} />
|
</form> : day.method === "manual" ? <Checkbox label="Completed on this date" checked={day.complete} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked })} />
|
||||||
: day.tasks.map(task => <Checkbox key={task.taskId} label={task.name} checked={task.done} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked }, task.taskId)} />)}
|
: day.tasks.map(task => <Checkbox key={task.taskId} label={task.name} checked={task.done} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked }, task.taskId)} />)}
|
||||||
</>}
|
</>}
|
||||||
<p role="status">{notice}</p>
|
<FormMessage>{notice}</FormMessage>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function HabitHistory({
|
|||||||
editor,
|
editor,
|
||||||
onExpired,
|
onExpired,
|
||||||
onHistorySaved,
|
onHistorySaved,
|
||||||
|
calendarOnly = false,
|
||||||
}: {
|
}: {
|
||||||
habit: TodayHabit;
|
habit: TodayHabit;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -35,6 +36,7 @@ export function HabitHistory({
|
|||||||
editor?: ReactNode;
|
editor?: ReactNode;
|
||||||
onExpired?: () => void;
|
onExpired?: () => void;
|
||||||
onHistorySaved?: () => void;
|
onHistorySaved?: () => void;
|
||||||
|
calendarOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [selectedDate, setSelectedDate] = useState<string>();
|
const [selectedDate, setSelectedDate] = useState<string>();
|
||||||
const [editingDay, setEditingDay] = useState(false);
|
const [editingDay, setEditingDay] = useState(false);
|
||||||
@@ -132,18 +134,19 @@ export function HabitHistory({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="ds-actions">
|
<div className="ds-action-row ds-history-actions">
|
||||||
<Button variant="text" disabled={disabled} onClick={() => { setSelectedDate(selectedDate ?? date); setEditingDay(!editingDay); }} aria-expanded={editingDay}>
|
<Button variant="text" disabled={disabled} onClick={() => { setSelectedDate(selectedDate ?? date); setEditingDay(!editingDay); }} aria-expanded={editingDay}>
|
||||||
{editingDay ? "Close check-in editor" : "Edit selected check-in"}
|
{editingDay ? "Close check-in editor" : "Edit selected check-in"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{editingDay && <>
|
{editingDay && <div className="ds-history-editor">
|
||||||
<Field label="Check-in date">{id => <input id={id} type="date" max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field>
|
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field><p className="ds-supporting-copy">Update the progress recorded for this day.</p></div>
|
||||||
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
|
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
|
||||||
onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
||||||
</>}
|
</div>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
if (calendarOnly) return chart;
|
||||||
return (
|
return (
|
||||||
<HabitChart
|
<HabitChart
|
||||||
id={`history-${habit.habitId}`}
|
id={`history-${habit.habitId}`}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FormMessage, SettingRow } from "./design-system/WorkspacePanel";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { habitRequest } from "../lib/dashboard";
|
import { habitRequest } from "../lib/dashboard";
|
||||||
import { defaultReminder, type ReminderResponse } from "../reminders/contracts";
|
import { defaultReminder, type ReminderResponse } from "../reminders/contracts";
|
||||||
@@ -23,10 +24,8 @@ export function ReminderSettings({ timezone }: { timezone: string }) {
|
|||||||
.catch(error => { if (!controller.signal.aborted) setError(error.message); });
|
.catch(error => { if (!controller.signal.aborted) setError(error.message); });
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [attempt]);
|
}, [attempt]);
|
||||||
return <div className="ds-inline-item-form" aria-label="Daily reminders">
|
return <SettingRow title="Daily reminders" description={<><p>A private Discord nudge when you still have habits left today. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
|
||||||
<h3 className="type-ui-heading">Daily reminders</h3>
|
{!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders…</p> : <form className="ds-form-stack" onSubmit={async event => {
|
||||||
<p>One private Discord message when you still have habits left today. Times follow {timezone}. No habit names are sent.</p>
|
|
||||||
{!settings ? <Button onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <form onSubmit={async event => {
|
|
||||||
event.preventDefault(); if (saving.current) return;
|
event.preventDefault(); if (saving.current) return;
|
||||||
const form = event.currentTarget;
|
const form = event.currentTarget;
|
||||||
const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
|
const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
|
||||||
@@ -38,19 +37,19 @@ export function ReminderSettings({ timezone }: { timezone: string }) {
|
|||||||
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
|
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
|
||||||
finally { saving.current = false; setBusy(false); }
|
finally { saving.current = false; setBusy(false); }
|
||||||
}}>
|
}}>
|
||||||
{!settings.available && <p>Discord reminders are not configured on this server.</p>}
|
{!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>}
|
||||||
<fieldset className="ds-inline-item-fields" disabled={busy}>
|
<fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy}>
|
||||||
<Checkbox label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
|
<Checkbox className="ds-preference-toggle" description="At most one message per day. Turn off anytime." label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
|
||||||
<div className="ds-form-grid">
|
<div className="ds-clock-fields">
|
||||||
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
|
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
|
||||||
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
|
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
|
||||||
</div>
|
</div>
|
||||||
<p className="type-small">During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.</p>
|
<p className="ds-supporting-copy">During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.</p>
|
||||||
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save reminders"}</Button>
|
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy}>{busy ? "Saving…" : "Save reminders"}</Button></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{settings.lastDelivery && <p className="type-small">{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</p>}
|
{settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>}
|
||||||
</form>}
|
</form>}
|
||||||
{error && <p role="alert">{error}</p>}
|
<FormMessage error>{error}</FormMessage>
|
||||||
<p role="status">{notice}</p>
|
<FormMessage>{notice}</FormMessage>
|
||||||
</div>;
|
</SettingRow>;
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/components/design-system/WorkspacePanel.tsx
Normal file
29
src/components/design-system/WorkspacePanel.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useId, type ReactNode } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Button } from "./primitives";
|
||||||
|
|
||||||
|
/** Shared flat panel for secondary workspace flows. */
|
||||||
|
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel" }: {
|
||||||
|
id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string;
|
||||||
|
}) {
|
||||||
|
return <section id={id} className="ds-workspace-panel" aria-label={title}>
|
||||||
|
<header className="ds-panel-heading">
|
||||||
|
<div><p className="ds-eyebrow">{eyebrow}</p><h2>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
|
||||||
|
{onClose && <Button variant="text" className="ds-panel-close" onClick={onClose} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent label, explanation and control columns for preference forms. */
|
||||||
|
export function SettingRow({ title, description, children }: { title: string; description: ReactNode; children: ReactNode }) {
|
||||||
|
const id = useId();
|
||||||
|
return <section className="ds-setting-row" aria-labelledby={id}>
|
||||||
|
<header className="ds-setting-heading"><h3 id={id}>{title}</h3><div>{description}</div></header>
|
||||||
|
<div className="ds-setting-controls">{children}</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormMessage({ children, error = false }: { children: ReactNode; error?: boolean }) {
|
||||||
|
return <p className={`ds-form-message${error ? " ds-form-message--error" : ""}`} role={error ? "alert" : "status"}>{children}</p>;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Archive, Settings2 } from "lucide-react";
|
||||||
import { ArchivedHabits } from "../components/ArchivedHabits";
|
import { ArchivedHabits } from "../components/ArchivedHabits";
|
||||||
import { AccountSettings } from "../components/AccountSettings";
|
import { AccountSettings } from "../components/AccountSettings";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@@ -357,9 +358,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ds-actions">
|
<div className="ds-action-row ds-primary-actions">
|
||||||
<Button variant="text" disabled={busy} aria-expanded={archive} onClick={() => setArchive(!archive)}>Archived habits</Button>
|
|
||||||
<Button variant="text" disabled={busy} aria-expanded={settings} onClick={() => setSettings(!settings)}>Settings</Button>
|
|
||||||
<Button
|
<Button
|
||||||
id="add-habit"
|
id="add-habit"
|
||||||
disabled={adding || busy || loading || needsRefresh}
|
disabled={adding || busy || loading || needsRefresh}
|
||||||
@@ -376,10 +375,14 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ds-action-row ds-utility-row">
|
||||||
|
<Button variant="text" disabled={busy} aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button>
|
||||||
|
<Button variant="text" disabled={busy} aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</WelcomePanel>
|
</WelcomePanel>
|
||||||
{archive && today && <ArchivedHabits date={today.date} revision={revision} onExpired={onExpired} onChanged={() => setAttempt(value => value + 1)} />}
|
{archive && today && <ArchivedHabits onClose={() => setArchive(false)} date={today.date} revision={revision} onExpired={onExpired} onChanged={() => setAttempt(value => value + 1)} />}
|
||||||
{settings && <AccountSettings user={user} onChanged={onExpired} onClose={() => setSettings(false)} />}
|
{settings && <AccountSettings user={user} onChanged={onExpired} onClose={() => setSettings(false)} />}
|
||||||
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
|
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -1435,3 +1435,73 @@
|
|||||||
.ds-editable-task-row { gap: 4px; }
|
.ds-editable-task-row { gap: 4px; }
|
||||||
.ds-inline-item-form { padding: 16px; }
|
.ds-inline-item-form { padding: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Secondary workspace flows share the same flat surfaces and editorial hierarchy. */
|
||||||
|
.ds-action-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.ds-action-row > .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; max-width: 100%; overflow-wrap: anywhere; }
|
||||||
|
.ds-action-row > .ds-button svg { flex: 0 0 auto; }
|
||||||
|
.ds-primary-actions { margin-top: 24px; }
|
||||||
|
.ds-utility-row { border-top: 1px solid var(--ds-rule); padding-top: 12px; margin-top: 20px; gap: 4px; }
|
||||||
|
.ds-utility-row .ds-button { color: var(--ds-secondary); @apply type-small; }
|
||||||
|
.ds-utility-row .ds-button[aria-expanded="true"] { color: var(--ds-ink); background: var(--ds-paper); }
|
||||||
|
.ds-workspace-panel { border: 1px solid var(--ds-rule); padding: 32px; margin: 32px 0 40px; min-width: 0; }
|
||||||
|
.ds-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 32px; }
|
||||||
|
.ds-panel-heading > div { min-width: 0; }
|
||||||
|
.ds-panel-heading h2 { margin-top: 10px; @apply type-section; overflow-wrap: anywhere; }
|
||||||
|
.ds-panel-description { margin-top: 12px !important; color: var(--ds-secondary); max-width: 52ch; }
|
||||||
|
.ds-panel-close { flex: 0 0 44px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ds-rule); }
|
||||||
|
.ds-setting-row { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 48px; padding: 28px 0; border-top: 1px solid var(--ds-rule); }
|
||||||
|
.ds-setting-row:last-of-type { padding-bottom: 0; }
|
||||||
|
.ds-setting-heading h3 { @apply type-ui-heading; }
|
||||||
|
.ds-setting-heading > div { display: grid; gap: 12px; margin-top: 10px; max-width: 32ch; color: var(--ds-secondary); @apply type-small; }
|
||||||
|
.ds-setting-controls { display: grid; gap: 16px; min-width: 0; align-content: start; }
|
||||||
|
.ds-form-stack { display: grid; gap: 20px; min-width: 0; align-content: start; }
|
||||||
|
.ds-form-stack .ds-field { margin-bottom: 0; }
|
||||||
|
.ds-supporting-copy, .ds-meta-label { color: var(--ds-secondary); @apply type-small; overflow-wrap: anywhere; }
|
||||||
|
.ds-meta-label { font-variant-numeric: tabular-nums; }
|
||||||
|
.ds-clock-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.ds-clock-fields input { font-variant-numeric: tabular-nums; }
|
||||||
|
.ds-preference-toggle { padding: 16px; background: var(--ds-surface); border: 1px solid var(--ds-rule); }
|
||||||
|
.ds-preference-toggle .ds-checkbox-description { margin-top: 4px; }
|
||||||
|
.ds-delivery-note { display: grid; gap: 6px; padding: 14px 16px; border-left: 2px solid var(--ds-control-border); background: var(--ds-surface); @apply type-small; }
|
||||||
|
.ds-confirmation { padding: 24px; background: var(--ds-surface); border-left: 2px solid var(--ds-ink); }
|
||||||
|
.ds-form-message { @apply type-small; color: var(--ds-secondary); overflow-wrap: anywhere; }
|
||||||
|
.ds-form-message:empty { display: none; }
|
||||||
|
.ds-form-message:not(:empty) { padding: 12px 0; }
|
||||||
|
.ds-form-message--error:not(:empty) { padding: 12px 16px; border-left: 2px solid var(--ds-ink); background: var(--ds-surface); color: var(--ds-ink); }
|
||||||
|
.ds-record { border-top: 1px solid var(--ds-rule); min-width: 0; }
|
||||||
|
.ds-record-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px 0; }
|
||||||
|
.ds-record-copy { min-width: 0; }
|
||||||
|
.ds-record-copy h3 { @apply type-title; overflow-wrap: anywhere; }
|
||||||
|
.ds-record-copy p { margin-top: 6px; color: var(--ds-secondary); @apply type-small; }
|
||||||
|
.ds-record-heading > .ds-action-row { flex-shrink: 0; }
|
||||||
|
.ds-disclosure-icon { transition: transform 160ms ease; }
|
||||||
|
[aria-expanded="true"] > .ds-disclosure-icon { transform: rotate(180deg); }
|
||||||
|
.ds-record-detail { padding: 8px 0 24px; }
|
||||||
|
.ds-empty-panel { display: flex; align-items: flex-start; gap: 18px; padding: 28px; background: var(--ds-surface); }
|
||||||
|
.ds-empty-panel > svg { flex-shrink: 0; color: var(--ds-secondary); }
|
||||||
|
.ds-empty-panel .ds-supporting-copy { margin-top: 8px; max-width: 48ch; }
|
||||||
|
.ds-history-actions { justify-content: flex-end; margin-top: 12px; }
|
||||||
|
.ds-history-editor { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 32px; padding: 24px; margin-top: 12px; border: 1px solid var(--ds-rule); background: var(--ds-surface); }
|
||||||
|
.ds-check-in-heading { display: grid; gap: 6px; }
|
||||||
|
.ds-check-in-controls { padding-left: 28px; border-left: 1px solid var(--ds-rule); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.ds-setting-row { gap: 28px; grid-template-columns: minmax(160px, 0.8fr) minmax(0, 1.6fr); }
|
||||||
|
.ds-clock-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.ds-clock-fields > .ds-field:first-child { grid-column: 1 / -1; }
|
||||||
|
.ds-record-heading { align-items: flex-start; flex-direction: column; gap: 16px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ds-workspace-panel { padding: 20px; margin: 24px 0 32px; }
|
||||||
|
.ds-panel-heading { gap: 16px; margin-bottom: 24px; }
|
||||||
|
.ds-panel-heading h2 { @apply type-title; }
|
||||||
|
.ds-panel-description { @apply type-small; }
|
||||||
|
.ds-setting-row { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 24px 0; }
|
||||||
|
.ds-setting-heading > div { max-width: none; }
|
||||||
|
.ds-confirmation, .ds-empty-panel { padding: 18px; }
|
||||||
|
.ds-history-editor { grid-template-columns: minmax(0, 1fr); padding: 20px; gap: 24px; }
|
||||||
|
.ds-check-in-controls { padding: 24px 0 0; border-left: 0; border-top: 1px solid var(--ds-rule); }
|
||||||
|
.ds-history-actions { justify-content: flex-start; }
|
||||||
|
.ds-record-heading > .ds-action-row { flex-shrink: 1; gap: 8px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .ds-disclosure-icon { transition: none; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user