Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
445 changes: 223 additions & 222 deletions dashboard/src/v2/SettingsPage.tsx

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { FunctionComponent, JSX } from "preact";
import type { SettingsPageState } from "../../hooks/use-settings-page-state.js";

const formatCategoryLabel = (category: SettingsPageState["activeCategory"]): string => (
`${category.charAt(0).toUpperCase()}${category.slice(1)}`
);

export const SettingsActivePanelStatus: FunctionComponent<{
state: SettingsPageState;
sticky?: boolean;
stickyTop?: string;
className?: string;
style?: JSX.CSSProperties;
}> = ({
state,
sticky = true,
stickyTop = "9.5rem",
className,
style,
}) => {
const { activeCategory, activeDirty, activeSaving, error, saveMessage, loading, resettingProject } = state;
const activeCategoryLabel = state.activeCategoryConfig?.label ?? formatCategoryLabel(activeCategory);

const panelStatus = error
? `${activeCategoryLabel} settings blocked: ${error}`
: resettingProject
? `${activeCategoryLabel} project override reset is pending.`
: activeSaving
? `${activeCategoryLabel} settings save is pending.`
: saveMessage
? `${activeCategoryLabel} settings saved. ${saveMessage}`
: activeDirty
? `${activeCategoryLabel} settings have local unsaved changes.`
: `${activeCategoryLabel} settings are saved.`;
const visibleSaveState = error
? "Blocked"
: loading
? "Loading"
: resettingProject
? "Resetting"
: activeSaving
? "Saving"
: activeDirty
? "Unsaved changes"
: saveMessage
? "Saved"
: "Saved";
const statusStyle = sticky
? ({
...style,
"--settings-active-panel-top": stickyTop,
} as JSX.CSSProperties)
: style;
const statusClassName = [
sticky ? "sticky top-[var(--settings-active-panel-top)] z-20 mb-3" : null,
"flex min-w-0 flex-wrap items-center gap-2 overflow-visible rounded-[1rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] px-3 py-2 text-xs font-semibold text-slate-500 shadow-[var(--elevation-base)] backdrop-blur-2xl dark:text-slate-300",
className,
].filter(Boolean).join(" ");

return (
<>
<div role={error ? "alert" : "status"} aria-live={error ? "assertive" : "polite"} aria-atomic="true" className="sr-only">
{panelStatus}
</div>
<div
data-settings-sticky={sticky ? "active-panel" : undefined}
style={statusStyle}
className={statusClassName}
>
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400 dark:text-slate-500">Active panel</span>
<span className="min-w-0 max-w-full break-words text-slate-800 dark:text-slate-100">{activeCategoryLabel}</span>
<span aria-hidden="true" className="text-slate-300 dark:text-slate-600">/</span>
<span className={`min-w-0 max-w-full break-words ${error ? "text-status-red" : activeSaving || loading || resettingProject ? "text-signal-700 dark:text-signal-300" : activeDirty ? "text-amber-700 dark:text-amber-200" : "text-status-green"}`}>
{visibleSaveState}
</span>
</div>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface SettingsCategoryRailProps {
onSwitchCategory: (categoryId: CategoryId) => void;
pendingCategory?: CategoryId | null;
disabledCategoryReason?: string | null;
className?: string;
}

export const SettingsCategoryRail: FunctionComponent<SettingsCategoryRailProps> = ({
Expand All @@ -42,6 +43,7 @@ export const SettingsCategoryRail: FunctionComponent<SettingsCategoryRailProps>
onSwitchCategory,
pendingCategory = null,
disabledCategoryReason = null,
className,
}) => {
const normalizedSearch = settingsSearch.trim().toLowerCase();
const railRef = useRef<HTMLElement | null>(null);
Expand Down Expand Up @@ -126,7 +128,10 @@ export const SettingsCategoryRail: FunctionComponent<SettingsCategoryRailProps>
onScroll={updateRailMetrics}
style={railHeightStyle}
data-motion-contract="selectionMovement"
className="scrollbar-hide flex min-w-0 flex-col gap-3 rounded-[1.75rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] p-3 backdrop-blur-2xl shadow-[var(--elevation-base)] lg:sticky lg:top-16 lg:max-h-[var(--settings-category-rail-available-height)] lg:overflow-y-auto lg:overscroll-contain"
className={[
"scrollbar-hide flex min-w-0 flex-col gap-3 rounded-[1.75rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] p-3 backdrop-blur-2xl shadow-[var(--elevation-base)] lg:sticky lg:top-16 lg:max-h-[var(--settings-category-rail-available-height)] lg:overflow-y-auto lg:overscroll-contain",
className,
].filter(Boolean).join(" ")}
>
<div
id={instructionsId}
Expand Down
51 changes: 5 additions & 46 deletions dashboard/src/v2/components/settings/SettingsContentPanels.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FunctionComponent, JSX } from "preact";
import type { FunctionComponent } from "preact";
import type { SettingsPageState } from "../../hooks/use-settings-page-state.js";
import { SettingsGeneralPanel } from "./panels/SettingsGeneralPanel.js";
import { SettingsAppearancePanel } from "./panels/SettingsAppearancePanel.js";
Expand All @@ -12,42 +12,15 @@ import { SettingsMcpPanel } from "./panels/SettingsMcpPanel.js";
import { SettingsDangerPanel } from "./panels/SettingsDangerPanel.js";
import { ActionFeedbackRegion } from "../ui/ActionFeedbackRegion.js";
import { useInteractionTokens } from "../../lib/motion/tokens.js";
import { SettingsActivePanelStatus } from "./SettingsActivePanelStatus.js";

export const SettingsContentPanels: FunctionComponent<{
state: SettingsPageState;
stickyTop?: string;
}> = ({ state, stickyTop = "9.5rem" }) => {
showActivePanelStatus?: boolean;
}> = ({ state, stickyTop = "9.5rem", showActivePanelStatus = true }) => {
const { activeCategory, activeDirty, activeSaving, error, saveMessage, loading, resettingProject } = state;
const tokens = useInteractionTokens();
const activeCategoryLabel = state.activeCategoryConfig?.label ?? `${activeCategory.charAt(0).toUpperCase()}${activeCategory.slice(1)}`;

const panelStatus = error
? `${activeCategoryLabel} settings blocked: ${error}`
: resettingProject
? `${activeCategoryLabel} project override reset is pending.`
: activeSaving
? `${activeCategoryLabel} settings save is pending.`
: saveMessage
? `${activeCategoryLabel} settings saved. ${saveMessage}`
: activeDirty
? `${activeCategoryLabel} settings have local unsaved changes.`
: `${activeCategoryLabel} settings are saved.`;
const visibleSaveState = error
? "Blocked"
: loading
? "Loading"
: resettingProject
? "Resetting"
: activeSaving
? "Saving"
: activeDirty
? "Unsaved changes"
: saveMessage
? "Saved"
: "Saved";
const stickyStyle = {
"--settings-active-panel-top": stickyTop,
} as JSX.CSSProperties;

const renderPanel = () => {
switch (activeCategory) {
Expand Down Expand Up @@ -78,21 +51,7 @@ export const SettingsContentPanels: FunctionComponent<{

return (
<div aria-busy={activeSaving || loading || resettingProject ? "true" : undefined} data-reset-busy={resettingProject ? "true" : undefined} data-settings-state={error ? "error" : resettingProject ? "resetting" : activeSaving ? "saving" : activeDirty ? "dirty" : "saved"}>
<div role={error ? "alert" : "status"} aria-live={error ? "assertive" : "polite"} aria-atomic="true" className="sr-only">
{panelStatus}
</div>
<div
data-settings-sticky="active-panel"
style={stickyStyle}
className="sticky top-[var(--settings-active-panel-top)] z-20 mb-3 flex min-w-0 flex-wrap items-center gap-2 overflow-visible rounded-[1rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] px-3 py-2 text-xs font-semibold text-slate-500 shadow-[var(--elevation-base)] backdrop-blur-2xl dark:text-slate-300"
>
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400 dark:text-slate-500">Active panel</span>
<span className="min-w-0 max-w-full break-words text-slate-800 dark:text-slate-100">{activeCategoryLabel}</span>
<span aria-hidden="true" className="text-slate-300 dark:text-slate-600">/</span>
<span className={`min-w-0 max-w-full break-words ${error ? "text-status-red" : activeSaving || loading || resettingProject ? "text-signal-700 dark:text-signal-300" : activeDirty ? "text-amber-700 dark:text-amber-200" : "text-status-green"}`}>
{visibleSaveState}
</span>
</div>
{showActivePanelStatus ? <SettingsActivePanelStatus state={state} stickyTop={stickyTop} /> : null}
<ActionFeedbackRegion
status={error ? "error" : loading || activeSaving || resettingProject ? "pending" : saveMessage ? "success" : activeDirty ? "warning" : "idle"}
message={error || (loading ? "Loading settings without clearing current values..." : resettingProject ? "Resetting project overrides. Current values remain visible." : activeSaving ? "Saving settings. Current values remain visible." : saveMessage || (activeDirty ? "You have unsaved changes in this settings scope." : null))}
Expand Down
131 changes: 131 additions & 0 deletions dashboard/src/v2/components/settings/SettingsScopeControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { JSX } from "preact";
import { ShieldCheck } from "lucide-preact";
import type { SettingsScope } from "../../hooks/use-settings-page-state.js";
import type { Source } from "../../types.js";

export interface SettingsScopeControlsProps {
activeScope: SettingsScope;
setActiveScope: (scope: SettingsScope) => void | Promise<void>;
selectedProject: Source | null;
scopeStatusText: string;
projectSourceSummary: string | null;
filteredCategoryCount: number;
isSearchActive: boolean;
activeDirty: boolean;
activeSaving: boolean;
saveMessage: string | null;
error: string | null;
interactionStyle: JSX.CSSProperties;
}

const scopeStatusId = "settings-scope-status";

const contextChipClassName = "min-w-0 max-w-full break-words rounded-[1rem] border border-black/[0.06] bg-white/70 px-4 py-2 text-xs font-semibold text-slate-500 backdrop-blur-2xl sm:rounded-full dark:border-white/[0.06] dark:bg-void-800/60 dark:text-slate-300";
const projectSummaryChipClassName = "min-w-0 max-w-full break-words rounded-[1rem] border border-slate-500/15 bg-slate-500/[0.06] px-4 py-2 text-xs font-semibold text-slate-600 backdrop-blur-2xl sm:rounded-full dark:border-slate-300/15 dark:bg-slate-300/[0.08] dark:text-slate-300";
const projectUnavailableChipClassName = "min-w-0 max-w-full break-words rounded-[1rem] border border-amber-500/20 bg-amber-500/10 px-4 py-2 text-xs font-semibold text-amber-700 backdrop-blur-2xl sm:rounded-full dark:border-amber-300/20 dark:bg-amber-300/10 dark:text-amber-200";

export function SettingsScopeControls({
activeScope,
setActiveScope,
selectedProject,
scopeStatusText,
projectSourceSummary,
filteredCategoryCount,
isSearchActive,
activeDirty,
activeSaving,
saveMessage,
error,
interactionStyle,
}: SettingsScopeControlsProps): JSX.Element {
const scopeButtonClassName = (scope: SettingsScope): string => `h-8 rounded-[1rem] px-4 py-2 text-xs font-bold uppercase tracking-[0.16em] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring-signal)] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-void-900 ${
activeScope === scope
? "bg-signal-500/[0.12] text-signal-700 dark:text-signal-300"
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
}`;

return (
<>
<div
role="radiogroup"
aria-label="Settings scope"
aria-describedby={`settings-scope-context settings-project-scope-disabled ${scopeStatusId}`}
className="rounded-2xl border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] p-1 backdrop-blur-2xl shadow-[var(--elevation-base)]"
>
<button
type="button"
role="radio"
aria-checked={activeScope === "system"}
onClick={() => { void setActiveScope("system"); }}
style={interactionStyle}
className={scopeButtonClassName("system")}
>
System
</button>
<button
type="button"
role="radio"
aria-checked={activeScope === "project"}
aria-describedby={!selectedProject ? "settings-project-scope-disabled" : undefined}
onClick={() => {
if (selectedProject) {
void setActiveScope("project");
}
}}
disabled={!selectedProject}
style={interactionStyle}
className={`${scopeButtonClassName("project")} disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none`}
>
Project
</button>
</div>
<div id={scopeStatusId} role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{scopeStatusText}
</div>

{activeScope === "system" ? (
<div id="settings-scope-context" className="sr-only">
Editing live system defaults.
</div>
) : (
<div id="settings-scope-context" className={contextChipClassName}>
{selectedProject
? `Editing overrides for ${selectedProject.name}`
: "Select a project to edit overrides"}
</div>
)}
{projectSourceSummary ? (
<div className={projectSummaryChipClassName}>
{projectSourceSummary}
</div>
) : null}
{!selectedProject ? (
<div id="settings-project-scope-disabled" className={projectUnavailableChipClassName}>
Project scope unlocks after selecting a project.
</div>
) : (
<div id="settings-project-scope-disabled" className="sr-only">
Project scope is available for the selected project.
</div>
)}

{isSearchActive ? (
<div className={contextChipClassName}>
{filteredCategoryCount} visible categor{filteredCategoryCount === 1 ? "y" : "ies"}
</div>
) : null}

{activeDirty ? (
<div className={projectUnavailableChipClassName}>
Unsaved edits
</div>
) : null}
{!activeDirty && !activeSaving && saveMessage && !error ? (
<div className="inline-flex min-w-0 max-w-full items-center gap-1.5 break-words rounded-[1rem] border border-status-green/20 bg-status-green/10 px-4 py-2 text-xs font-semibold text-status-green backdrop-blur-2xl sm:rounded-full">
<ShieldCheck className="h-3.5 w-3.5" strokeWidth={2.2} />
Saved
</div>
) : null}
</>
);
}
Loading