diff --git a/frontend/apps/web/src/pages/ListPage.tsx b/frontend/apps/web/src/pages/ListPage.tsx index 84a4d68d..70a3917e 100644 --- a/frontend/apps/web/src/pages/ListPage.tsx +++ b/frontend/apps/web/src/pages/ListPage.tsx @@ -28,6 +28,7 @@ import { EmptyState, Modal, Pagination, + Popover, RecordCardList, Table, useMediaQuery, @@ -536,37 +537,49 @@ export function ListPage() { // parity — the actions selector leads the toolbar). leading={ canRunActions && (selected.size > 0 || selectAcross) ? ( -
- - {actionsOpen && ( -
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. + setActionsOpen(false)} + align="left" + panelClassName="min-w-56 max-w-md py-1" + trigger={ + - ))} -
- )} -
+ Actions · {effectiveCount} ▾ + + } + > +
+ {actions.map((a) => ( + + ))} +
+ ) : null } trailing={ diff --git a/frontend/packages/settings/README.md b/frontend/packages/settings/README.md index 808e05a0..e7423a7e 100644 --- a/frontend/packages/settings/README.md +++ b/frontend/packages/settings/README.md @@ -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 ``. `initTheme()` is @@ -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. diff --git a/frontend/packages/settings/src/AccountMenu.tsx b/frontend/packages/settings/src/AccountMenu.tsx new file mode 100644 index 00000000..852d4b24 --- /dev/null +++ b/frontend/packages/settings/src/AccountMenu.tsx @@ -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 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(() => 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 ( +
+ {identityLabel && ( +
+ Signed in as {identityLabel} + {roleLabel ? · {roleLabel} : null} +
+ )} + +
+ Appearance +
+
+ + +
+ +
+ +
+
+ ); +} diff --git a/frontend/packages/settings/src/SettingsModal.tsx b/frontend/packages/settings/src/SettingsModal.tsx deleted file mode 100644 index baf1ad57..00000000 --- a/frontend/packages/settings/src/SettingsModal.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// Settings dialog (#84) — opened from the sidebar cog. v1 holds the -// appearance (light / dark) toggle; it's a natural home for future -// per-user UI preferences. Uses the shared @dar/ui Modal so it matches -// the filter / delete / action confirms (overlay, Esc / backdrop close). - -import { useState } from 'react'; -import { LogOut, Moon, Sun } from 'lucide-react'; - -import { resolveTheme, setTheme, type Theme } from '@dar/customization'; -import { useApiClient, purgeLocalCache } from '@dar/data'; -import { Button, Modal } from '@dar/ui'; - -export function SettingsModal({ onClose }: { onClose: () => void }) { - const [theme, setThemeState] = useState(() => resolveTheme()); - const client = useApiClient(); - const [loggingOut, setLoggingOut] = useState(false); - - const choose = (next: Theme) => { - setTheme(next); - setThemeState(next); - }; - - 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(); - window.location.reload(); - }; - - const optionClass = (active: boolean): string => - [ - 'flex flex-1 items-center justify-center gap-2 rounded-md border px-3 py-2 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 ( - -
-
Appearance
-
- - -
-

Saved on this device.

-
- -
-
Session
- -

- Ends your session and clears cached data on this device. -

-
-
- ); -} diff --git a/frontend/packages/settings/src/index.ts b/frontend/packages/settings/src/index.ts index 73f5f282..af2ce387 100644 --- a/frontend/packages/settings/src/index.ts +++ b/frontend/packages/settings/src/index.ts @@ -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, diff --git a/frontend/packages/sidebar/package.json b/frontend/packages/sidebar/package.json index 03c2e6d7..c40e1c4e 100644 --- a/frontend/packages/sidebar/package.json +++ b/frontend/packages/sidebar/package.json @@ -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", diff --git a/frontend/packages/sidebar/src/Sidebar.tsx b/frontend/packages/sidebar/src/Sidebar.tsx index 9012f696..f35a093f 100644 --- a/frontend/packages/sidebar/src/Sidebar.tsx +++ b/frontend/packages/sidebar/src/Sidebar.tsx @@ -1,16 +1,17 @@ // @dar/sidebar — the SPA navigation chrome: the brand header, the -// per-user actions (Settings / Install), the model filter, the +// per-user identity menu (theme + sign out), the model filter, the // metadata-driven app/model nav, and the responsive drawer (static // column at ≥lg, off-canvas drawer below). The app shell composes // next to its own
content region. import { useEffect, useMemo, useState } from 'react'; -import { ChevronDown, Download, Menu, Settings } from 'lucide-react'; +import { ChevronDown, Download, Menu } from 'lucide-react'; import { Link, NavLink } from 'react-router-dom'; import { NAV_COLLAPSE_KEY, usePersistedSet } from '@dar/customization'; import { useRegistry } from '@dar/data'; -import { SettingsModal } from '@dar/settings'; +import { AccountMenu } from '@dar/settings'; +import { Popover } from '@dar/ui'; // The browser's `beforeinstallprompt` event (Chromium). Captured so we // can show an explicit "Install" affordance and call `.prompt()` on @@ -123,8 +124,10 @@ export function Sidebar() { // so it never eats horizontal space on a phone/tablet. ``drawerOpen`` // only affects the mobile/tablet presentation. const [drawerOpen, setDrawerOpen] = useState(false); - // Settings dialog (cog) — appearance / dark-mode toggle (#84). - const [settingsOpen, setSettingsOpen] = useState(false); + // Identity dropdown (#578) — anchored under the email-with-caret button; + // holds the theme toggle + Sign out (was a separate Settings modal in + // v1.0.2 and earlier). + const [accountOpen, setAccountOpen] = useState(false); // Collapsed app-group sections (#227), persisted per device via // @dar/customization (the single home for localStorage-backed prefs). const [collapsed, setCollapsed] = usePersistedSet(NAV_COLLAPSE_KEY); @@ -212,33 +215,56 @@ export function Sidebar() { )} {BRAND_TITLE} + {/* Identity dropdown (#578): a single email-with-caret button + replaces the v1.0.2 two-line " · " + Settings + button. The dropdown is mounted via the shared + (outside-click + Escape inherited from the same primitive + the list-page Actions menu uses, #575). The optional Install + affordance stays as a sibling button — orthogonal concern. */} {data?.user && ( -
- {data.user.display_name} - {data.user.is_superuser ? ' · superuser' : ''} +
+ setAccountOpen(false)} + align="left" + panelClassName="text-gray-700" + trigger={ + + } + > + setAccountOpen(false)} + /> + + {canInstall && ( + + )}
)} -
- - {canInstall && ( - - )} -
{showFilter && ( @@ -335,7 +361,6 @@ export function Sidebar() { - {settingsOpen && setSettingsOpen(false)} />} ); } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5b2bd006..928a60f6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: '@dar/settings': specifier: workspace:* version: link:../settings + '@dar/ui': + specifier: workspace:* + version: link:../ui devDependencies: '@types/react': specifier: ^18.3.0 diff --git a/pyproject.toml b/pyproject.toml index 144a2a31..12a90d4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-admin-react" -version = "1.0.2" +version = "1.0.3" description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin." authors = ["django-admin-react contributors"] license = "MIT"