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
73 changes: 43 additions & 30 deletions frontend/apps/web/src/pages/ListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
EmptyState,
Modal,
Pagination,
Popover,
RecordCardList,
Table,
useMediaQuery,
Expand Down Expand Up @@ -536,37 +537,49 @@ export function ListPage() {
// parity — the actions selector leads the toolbar).
leading={
canRunActions && (selected.size > 0 || selectAcross) ? (
<div className="relative">
<button
type="button"
onClick={() => setActionsOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={actionsOpen}
disabled={runningAction}
className="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50"
>
Actions · {effectiveCount} ▾
</button>
{actionsOpen && (
<div
role="menu"
className="absolute left-0 z-20 mt-1 min-w-48 rounded border border-gray-200 bg-white py-1 shadow-lg"
// Actions menu (#574 width / #575 outside-click): rendered
// through the shared <Popover> so the menu inherits the
// outside-click + Escape close behaviour from the same
// primitive the sidebar identity dropdown uses (#578). Width
// tracks the longest action label between sensible bounds —
// `min-w-56` (224px) so short labels don't collapse the
// menu, `max-w-md` (28rem / 448px) so a single ridiculously
// long label doesn't blow it open. Items are `whitespace-
// nowrap truncate` so a longer-than-max label gets `…` with
// the full text reachable via the existing `title=` tooltip.
<Popover
open={actionsOpen}
onClose={() => setActionsOpen(false)}
align="left"
panelClassName="min-w-56 max-w-md py-1"
trigger={
<button
type="button"
onClick={() => setActionsOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={actionsOpen}
disabled={runningAction}
className="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50"
>
{actions.map((a) => (
<button
key={a.name}
type="button"
role="menuitem"
onClick={() => requestAction(a)}
className="block w-full px-3 py-2 text-left text-sm hover:bg-gray-100"
title={a.description}
>
{a.label}
</button>
))}
</div>
)}
</div>
Actions · {effectiveCount} ▾
</button>
}
>
<div role="menu">
{actions.map((a) => (
<button
key={a.name}
type="button"
role="menuitem"
onClick={() => requestAction(a)}
className="block w-full truncate whitespace-nowrap px-3 py-2 text-left text-sm hover:bg-gray-100"
title={a.description ?? a.label}
>
{a.label}
</button>
))}
</div>
</Popover>
) : null
}
trailing={
Expand Down
23 changes: 16 additions & 7 deletions frontend/packages/settings/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
# @dar/settings

The **Settings dialog** and the user-preference state it owns.
The **identity dropdown panel** (theme + sign out) and the
user-preference state it owns.

## What lives here

- `SettingsModal.tsx` — the dialog opened from the sidebar cog. Built on
the shared `@dar/ui` `Modal` so it matches every other confirm/overlay
in the SPA. Today it holds the appearance (light / dark) toggle; it is
the home for future per-user UI preferences.
- `AccountMenu.tsx` — the dropdown panel rendered inside the sidebar's
email-with-caret button (`#578`). Holds the appearance (light / dark)
toggle and the Sign out action. The component is the **panel body
only**; the consumer wraps it in the shared `@dar/ui` `Popover` so
outside-click + Escape close behaviour is inherited from the same
primitive the list-page Actions menu uses.
- `theme.ts` — the light/dark preference: read/resolve (saved choice →
system default), persist to the `dar:theme` localStorage key, and
apply by toggling the `.dark` class on `<html>`. `initTheme()` is
Expand All @@ -17,12 +20,18 @@ The **Settings dialog** and the user-preference state it owns.

- Model-aware logic or anything that talks to the API. This package is
pure UI preference; it imports only `@dar/ui` (+ React / lucide).
- The sidebar chrome that *opens* the modal — that's `@dar/sidebar`.
- The sidebar chrome that *mounts* the panel — that's `@dar/sidebar`.
- The `.dark` utility remap CSS — that stays in the app's `index.css`
(Tailwind layer), since it's global styling, not component logic.

## Pointers

- Rendered by `@dar/sidebar` (the cog button) and bootstrapped by
- Rendered by `@dar/sidebar` (the identity dropdown) and bootstrapped by
`apps/web/src/main.tsx` (`initTheme`).
- Data-flow rule: a UI package never imports `@dar/api` (CLAUDE.md §7).

## History

- v1.0.3: replaced the prior `SettingsModal` with `AccountMenu`. The
modal-around-two-controls was heavier than warranted; the dropdown
is the right primitive for a profile menu.
110 changes: 110 additions & 0 deletions frontend/packages/settings/src/AccountMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// AccountMenu (#578) — the panel body for the sidebar's email-with-caret
// dropdown. Replaces the old "Settings" modal: same controls (theme
// toggle + Sign out) but rendered as a dropdown panel instead of a
// modal, since the controls don't warrant a dimmed-overlay modal.
//
// This component is the panel CONTENT only — the consumer wraps it in
// the shared <Popover> so outside-click + Escape close behaviour is
// inherited from the same primitive ListPage's Actions menu uses.

import { useState } from 'react';
import { LogOut, Moon, Sun } from 'lucide-react';

import { resolveTheme, setTheme, type Theme } from '@dar/customization';
import { purgeLocalCache, useApiClient } from '@dar/data';

export interface AccountMenuProps {
/** Hide the panel after a menu action runs (theme stays — only Sign out closes). */
onAfterAction?: (() => void) | undefined;
/** Email / display name shown as a small grayed caption above the controls. Optional. */
identityLabel?: string | undefined;
/** Role caption appended after the email (e.g. "superuser"). Optional. */
roleLabel?: string | undefined;
}

export function AccountMenu({ onAfterAction, identityLabel, roleLabel }: AccountMenuProps) {
const [theme, setThemeState] = useState<Theme>(() => resolveTheme());
const client = useApiClient();
const [loggingOut, setLoggingOut] = useState(false);

const choose = (next: Theme) => {
setTheme(next);
setThemeState(next);
// Theme is a sticky control — keep the menu open so the operator can
// see the change reflect immediately before deciding to close it.
};

const handleLogout = async () => {
setLoggingOut(true);
try {
await client.logout();
} catch {
// Logout is idempotent server-side; even on a transient network
// error we still bounce the user out so the UI can't pretend
// they're still signed in.
}
// Drop every cached server response + per-model UI hint so a logged
// out (or next) user can't read the previous session's data out of
// localStorage. Then a full reload re-runs the auth gate, routing an
// anonymous session to the login screen with clean in-memory state.
purgeLocalCache();
onAfterAction?.();
window.location.reload();
};

const optionClass = (active: boolean): string =>
[
'flex flex-1 items-center justify-center gap-1.5 rounded-md border px-2 py-1.5 text-sm font-medium',
active
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-gray-300 text-gray-700 hover:bg-gray-50',
].join(' ');

return (
<div className="w-56 p-3">
{identityLabel && (
<div className="mb-2 truncate text-xs text-gray-500" title={identityLabel}>
Signed in as <span className="font-medium text-gray-700">{identityLabel}</span>
{roleLabel ? <span className="text-gray-400"> · {roleLabel}</span> : null}
</div>
)}

<div className="mb-1 text-xs font-medium uppercase tracking-wide text-gray-500">
Appearance
</div>
<div className="flex gap-2">
<button
type="button"
role="menuitem"
onClick={() => choose('light')}
className={optionClass(theme === 'light')}
aria-pressed={theme === 'light'}
>
<Sun className="h-4 w-4" aria-hidden /> Light
</button>
<button
type="button"
role="menuitem"
onClick={() => choose('dark')}
className={optionClass(theme === 'dark')}
aria-pressed={theme === 'dark'}
>
<Moon className="h-4 w-4" aria-hidden /> Dark
</button>
</div>

<div className="mt-3 border-t border-gray-200 pt-3">
<button
type="button"
role="menuitem"
onClick={handleLogout}
disabled={loggingOut}
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-gray-300 px-2 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
<LogOut className="h-4 w-4" aria-hidden />
{loggingOut ? 'Signing out…' : 'Sign out'}
</button>
</div>
</div>
);
}
82 changes: 0 additions & 82 deletions frontend/packages/settings/src/SettingsModal.tsx

This file was deleted.

10 changes: 5 additions & 5 deletions frontend/packages/settings/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// @dar/settings — the Settings dialog + the appearance (theme) state it
// owns. Isolated so the modal can grow its own per-user preference
// surface without touching the app shell or the sidebar.
// @dar/settings — per-user UI preferences (appearance) and session
// controls (Sign out), exposed as a dropdown panel mounted from the
// sidebar identity area (#578).

export { SettingsModal } from './SettingsModal';
export { AccountMenu, type AccountMenuProps } from './AccountMenu';
// Theme state lives in @dar/customization (the home for all
// localStorage-backed UI customization); re-exported here so the app
// shell + Settings modal keep importing it from @dar/settings.
// shell + AccountMenu keep importing it from @dar/settings.
export {
applyTheme,
getStoredTheme,
Expand Down
3 changes: 2 additions & 1 deletion frontend/packages/sidebar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"dependencies": {
"@dar/customization": "workspace:*",
"@dar/data": "workspace:*",
"@dar/settings": "workspace:*"
"@dar/settings": "workspace:*",
"@dar/ui": "workspace:*"
},
"peerDependencies": {
"lucide-react": "^1.16.0",
Expand Down
Loading
Loading