diff --git a/src/App.css b/src/App.css index 5b57fd17..28cfbbeb 100644 --- a/src/App.css +++ b/src/App.css @@ -825,12 +825,11 @@ white-space: nowrap; } -/* On a phone, many options make two even rows; Segmented sets the columns. */ -@media (max-width: 512px) { - .visibility-toggle--many { - display: grid; - grid-template-columns: repeat(var(--segmented-columns), minmax(0, 1fr)); - } +/* Many options make two even rows, whatever the width, so their fit doesn't + hang on the style's font or the language; Segmented sets the columns. */ +.visibility-toggle--many { + display: grid; + grid-template-columns: repeat(var(--segmented-columns), minmax(0, 1fr)); } .visibility-toggle button.active { @@ -2720,11 +2719,17 @@ body.has-player .chat-fab { gap: 2.5rem; } -/* ── Page header ── - A page that edits nothing (settings, manage, notifications) names itself - with a bare h1, a size above its sections' h2, and keeps whatever goes - with it (a count) at the row's end. The edit forms cap their panels with +/* ── Menu pages ── + The pages the user menu and the bell lead to (settings, manage, + notifications) share one frame: a narrow column, a bare h1 with its icon + (PageHeader), then groups under a small ruled label (PageGroup) whose + content sits on surface cards. The edit forms cap their panels with .form-page-header instead. */ +.page-content--narrow { + max-width: calc(680px + 2.5rem); + gap: 2rem; +} + .page-header { display: flex; align-items: center; @@ -2744,6 +2749,68 @@ body.has-player .chat-fab { overflow-wrap: anywhere; } +.page-title-icon { + font-size: var(--text-xl); +} + +.page-group-label { + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-text-muted); + margin: 0 0 0.6rem; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.page-group-label::after { + content: ""; + flex: 1; + height: 1px; + background: var(--color-border-subtle); +} + +/* The notification items are cards of their own, with an unread bar. */ +.page-card, +.notification-item { + background: var(--color-surface); + border-radius: var(--radius-xl); + border: 1px solid var(--color-border-subtle); +} + +.page-card { + padding: 1rem; +} + +/* A card of settings rules its rows apart; they keep the grid's columns, so + the labels still line up. */ +.page-card.profile-appearance-grid { + padding-block: 0; + row-gap: 0; +} + +.page-card > .profile-appearance-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + justify-items: start; + padding-block: 0.75rem; +} + +.page-card > .profile-appearance-row + .profile-appearance-row { + border-top: 1px solid var(--color-border-subtle); +} + +@media (max-width: 512px) { + .page-card > .profile-appearance-row { + display: flex; + align-items: flex-start; + } +} + .page-content--centered { align-items: center; padding-top: 2.5rem; @@ -6498,17 +6565,6 @@ body.has-player .chat-fab { } /* ── Notifications page ── */ -.notifications-page { - max-width: 680px; - margin: 0 auto; - padding: 1.5rem 1rem 3rem; -} -.notifications-page > .page-header { - margin-bottom: 1.75rem; -} -.notifications-title-bell { - font-size: var(--text-lg); -} .notifications-unread-pill { font-size: var(--text-xs); font-weight: 700; @@ -6540,26 +6596,6 @@ body.has-player .chat-fab { line-height: 1.5; } -.notif-group { - margin-bottom: 2rem; -} -.notif-group-label { - font-size: var(--text-2xs); - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--color-text-muted); - margin: 0 0 0.6rem; - display: flex; - align-items: center; - gap: 0.6rem; -} -.notif-group-label::after { - content: ""; - flex: 1; - height: 1px; - background: var(--color-border-subtle); -} .notification-list { list-style: none; margin: 0; @@ -6569,9 +6605,6 @@ body.has-player .chat-fab { gap: 1rem; } .notification-item { - background: var(--color-surface); - border-radius: var(--radius-xl); - border: 1px solid var(--color-border-subtle); border-left: 3px solid transparent; transition: background var(--dur-fast), border-color var(--dur-fast); overflow: hidden; diff --git a/src/components/layout/PageGroup.tsx b/src/components/layout/PageGroup.tsx new file mode 100644 index 00000000..429dc787 --- /dev/null +++ b/src/components/layout/PageGroup.tsx @@ -0,0 +1,28 @@ +/** + * Renders one labelled group of a page under a `PageHeader`: a + * settings section, the category manager, a day of notifications. + * + * @module + */ + +import type { ReactNode } from "react"; + +/** Passed to {@link PageGroup} by `Settings`, `Manage` and `Notifications`. */ +export interface PageGroupProps { + /** It shows as a small capitalized label, ruled to the column's end. */ + label: ReactNode; + /** Usually a `.page-card`, or the notifications' list of cards. */ + children: ReactNode; +} + +/** Renders a `
` headed by `label` as its h2. */ +export function PageGroup( + { label, children }: PageGroupProps, +): React.JSX.Element { + return ( +
+

{label}

+ {children} +
+ ); +} diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx new file mode 100644 index 00000000..82d70b7d --- /dev/null +++ b/src/components/layout/PageHeader.tsx @@ -0,0 +1,35 @@ +/** + * Renders the title row of the pages the user menu and the bell lead to: + * settings, manage and notifications. + * + * @module + */ + +import type { ReactNode } from "react"; + +/** Passed to {@link PageHeader} by `Settings`, `Manage` and `Notifications`. */ +export interface PageHeaderProps { + /** An emoji before the title, hidden from screen readers. */ + icon: string; + title: ReactNode; + /** It goes at the end of the row, such as the notifications' unread count. */ + children?: ReactNode; +} + +/** + * Renders the page's h1 with its icon, then `children`, as the first block of + * a narrow `PageShell`; `PageGroup`s follow it. + */ +export function PageHeader( + { icon, title, children }: PageHeaderProps, +): React.JSX.Element { + return ( +
+

+ + {title} +

+ {children} +
+ ); +} diff --git a/src/components/layout/PageShell.tsx b/src/components/layout/PageShell.tsx index b9217073..15aae13e 100644 --- a/src/components/layout/PageShell.tsx +++ b/src/components/layout/PageShell.tsx @@ -13,17 +13,24 @@ export interface PageShellProps { children: ReactNode; /** Centers the content, for a lone card such as a form or an error. */ centered?: boolean; + /** + * Narrows the content to the column the user menu's and the bell's pages + * share, under a `PageHeader`. + */ + narrow?: boolean; } /** Renders {@link AppHeader}, then the page's content as the `
`. */ export function PageShell( - { children, centered = false }: PageShellProps, + { children, centered = false, narrow = false }: PageShellProps, ): React.JSX.Element { return (
{children}
diff --git a/src/components/shared/Segmented.tsx b/src/components/shared/Segmented.tsx index b17d57a8..d7a2c571 100644 --- a/src/components/shared/Segmented.tsx +++ b/src/components/shared/Segmented.tsx @@ -30,8 +30,9 @@ export interface SegmentedProps { } /** - * Past this many options, a phone lays them out in two even rows rather than - * one row that would wrap unevenly (`.visibility-toggle--many`). + * Past this many options, they lay out in two even rows rather than one row + * that the style's font or the language could make wrap unevenly + * (`.visibility-toggle--many`). */ const MAX_ONE_ROW = 4; diff --git a/src/pages/Manage.tsx b/src/pages/Manage.tsx index 5551e889..20f07e91 100644 --- a/src/pages/Manage.tsx +++ b/src/pages/Manage.tsx @@ -11,7 +11,8 @@ import { can } from "../utils/auth/permissions.ts"; import { useRequiredAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; -import { ProfileSection } from "../components/profile/ProfileSection.tsx"; +import { PageHeader } from "../components/layout/PageHeader.tsx"; +import { PageGroup } from "../components/layout/PageGroup.tsx"; import { CategoryManager } from "../components/category/CategoryManager.tsx"; import { NotFound } from "./NotFound.tsx"; @@ -24,15 +25,13 @@ export function Manage(): React.JSX.Element { const { user } = useRequiredAuth(); if (!can(user, "category:manage")) return ; return ( - -
-

- Manage -

-
- Categories}> - - + + Manage} /> + Categories}> +
+ +
+
); } diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 9b68f76c..a37f404b 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -47,6 +47,8 @@ import { useScrollSave, } from "../hooks/feed/useScrollSave.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageHeader } from "../components/layout/PageHeader.tsx"; +import { PageGroup } from "../components/layout/PageGroup.tsx"; import { dumpPath, playlistPath, userPath } from "../../shared/paths.ts"; type NotifIconKind = @@ -488,63 +490,57 @@ export function Notifications(): React.JSX.Element { const totalUnread = items.filter((n) => !n.read).length; return ( - -
-
-

- - Notifications -

- {totalUnread > 0 && ( - - - - )} -
+ + Notifications}> + {totalUnread > 0 && ( + + + + )} + - items.length === 0} - empty={ -
- -

- Nothing here yet. -

-

- - You'll be notified when someone follows your playlists, - upvotes your dumps, or posts new content. - -

-
- } - > - {({ data }) => ( - <> - {groupByDate(items).map((group) => ( -
-

{group.label}

-
    - {group.items.map((n) => ( - - ))} -
-
- ))} - - - )} -
-
+ items.length === 0} + empty={ +
+ +

+ Nothing here yet. +

+

+ + You'll be notified when someone follows your playlists, upvotes + your dumps, or posts new content. + +

+
+ } + > + {({ data }) => ( + <> + {groupByDate(items).map((group) => ( + +
    + {group.items.map((n) => ( + + ))} +
+
+ ))} + + + )} +
); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 2f209e7e..d2c054b6 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -22,12 +22,13 @@ import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { EMAIL_ENABLED } from "../config/instance.ts"; import { previewChime } from "../utils/shared/sound.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageHeader } from "../components/layout/PageHeader.tsx"; +import { PageGroup } from "../components/layout/PageGroup.tsx"; import { Segmented, type SegmentedOption, } from "../components/shared/Segmented.tsx"; import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx"; -import { ProfileSection } from "../components/profile/ProfileSection.tsx"; import { SettingRow } from "../components/profile/SettingRow.tsx"; // The styles' names are their own, in every language. @@ -77,14 +78,10 @@ export function Settings(): React.JSX.Element { const [changePasswordOpen, setChangePasswordOpen] = useState(false); return ( - -
-

- Settings -

-
- Account}> -
+ + Settings} /> + Account}> +
Username}> {user.username} @@ -98,9 +95,9 @@ export function Settings(): React.JSX.Element {
- - Appearance}> -
+ + Appearance}> +
Style}>
- - Feeds}> -
+ + Feeds}> +
Default tab}>
- - Notifications}> -
+ + Notifications}> +
Sounds}>
- - Email}> -
+ + Email}> +
}>

)} - + {changePasswordOpen && ( setChangePasswordOpen(false)} /> )} diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css index adcc135d..5a5959ee 100644 --- a/src/themes/brutalist.css +++ b/src/themes/brutalist.css @@ -321,8 +321,9 @@ color: var(--color-text); } -/* ── Notification items ──────────────────────────────────────────── */ -[data-style="brutalist"] .notification-item { +/* ── Notification items and page cards ─────────────────────────── */ +[data-style="brutalist"] .notification-item, +[data-style="brutalist"] .page-card { border: 2px solid var(--color-border); box-shadow: 2px 2px 0 var(--color-border); } diff --git a/src/themes/geocities.css b/src/themes/geocities.css index 5c32376b..2df83d8b 100644 --- a/src/themes/geocities.css +++ b/src/themes/geocities.css @@ -242,6 +242,7 @@ [data-style="geocities"] .dump-post-header, [data-style="geocities"] .auth-card, [data-style="geocities"] .notification-item, +[data-style="geocities"] .page-card, [data-style="geocities"] .user-result-item, [data-style="geocities"] .journal-card { border: 3px ridge var(--color-border); diff --git a/src/themes/nyt.css b/src/themes/nyt.css index ce26e21f..122919c0 100644 --- a/src/themes/nyt.css +++ b/src/themes/nyt.css @@ -154,7 +154,7 @@ [data-style="nyt"] .form-page-eyebrow, [data-style="nyt"] .playlist-badge, [data-style="nyt"] .dump-card-private-badge, -[data-style="nyt"] .notif-group-label, +[data-style="nyt"] .page-group-label, [data-style="nyt"] .follow-btn { text-transform: uppercase; letter-spacing: 0.08em; @@ -233,14 +233,19 @@ box-shadow: none; } -/* Notifications keep their left bar (the unread indicator), lose the box. */ -[data-style="nyt"] .notification-item { +/* Notifications and the cards of the pages beside them lose the box for a + rule underneath; notifications keep their left bar (the unread indicator). */ +[data-style="nyt"] .notification-item, +[data-style="nyt"] .page-card { border: none; - border-left: 3px solid transparent; border-bottom: 1px solid var(--color-border); box-shadow: none; } +[data-style="nyt"] .notification-item { + border-left: 3px solid transparent; +} + /* No box-hover; the headline underline is the affordance. */ [data-style="nyt"] .dump-card:hover, [data-style="nyt"] .playlist-card:hover, @@ -472,7 +477,7 @@ [data-style="nyt"] .notification-bell-icon, [data-style="nyt"] .notif-icon, [data-style="nyt"] .notifications-empty-icon, -[data-style="nyt"] .notifications-title-bell, +[data-style="nyt"] .page-title-icon, [data-style="nyt"] .chat-button-icon, [data-style="nyt"] .dump-card-preview-icon, [data-style="nyt"] .playlist-card-icon,