diff --git a/api/mail/layout.ts b/api/mail/layout.ts index 71d3bf16..cac9102e 100644 --- a/api/mail/layout.ts +++ b/api/mail/layout.ts @@ -393,9 +393,9 @@ export class EmailKit { private mode: "text" | "footer" | "row" | "sentence" = "text"; /** Draws Markdown's inline tokens with the kit's colors. */ - private readonly marked: Marked = (() => { - // deno-lint-ignore no-this-alias - const kit = this; + // The kit comes in as an argument: the renderer's methods need marked's + // `this` (its parser) as well as the kit's colors. + private readonly marked: Marked = ((kit: EmailKit) => { return new Marked({ renderer: { link({ href, tokens }) { @@ -408,7 +408,9 @@ export class EmailKit { }, strong({ tokens }) { const html = this.parser.parseInline(tokens); - const figure = /^[\d\s.,  ]+$/.test(html); + // A figure, its digits grouped by any space: `\s` takes the no-break + // ones locales group thousands with. + const figure = /^[\d\s.,]+$/.test(html); return ` ({ */ function highlightOf( type: NotificationType, - // deno-lint-ignore no-explicit-any - data: any, + data: NotificationData, ): Omit | null { + // `type` says which member `data` is, which TypeScript can't follow from a + // separate argument, so each case names it. switch (type) { - case "user_mentioned": - switch (data.contextType) { + case "user_mentioned": { + const d = data as UserMentionedData; + switch (d.contextType) { case "comment": return { kind: "mentionComment", - who: data.mentionerUsername, - target: dumpTarget(data, data.contextTitle), - quote: quoteOf(commentBody(data.contextId)), + who: d.mentionerUsername, + target: dumpTarget(d, d.contextTitle), + quote: quoteOf(commentBody(d.contextId)), }; case "dump": return { kind: "mentionDump", - who: data.mentionerUsername, - target: dumpTarget(data, data.contextTitle), + who: d.mentionerUsername, + target: dumpTarget(d, d.contextTitle), }; case "playlist": return { kind: "mentionPlaylist", - who: data.mentionerUsername, - target: playlistTarget(data, data.contextTitle), + who: d.mentionerUsername, + target: playlistTarget(d, d.contextTitle), }; case "chat": return { kind: "mentionChat", - who: data.mentionerUsername, + who: d.mentionerUsername, quote: quoteOf( (db.prepare(`SELECT body FROM chat_messages WHERE id = ?;`) - .get(data.contextId) as { body: string } | undefined)?.body, + .get(d.contextId) as { body: string } | undefined)?.body, ), }; } return null; - case "dump_commented": + } + case "dump_commented": { + const d = data as DumpCommentedData; return { kind: "comment", - who: data.commenterUsername, - target: dumpTarget(data, data.dumpTitle), - quote: quoteOf(commentBody(data.commentId)), + who: d.commenterUsername, + target: dumpTarget(d, d.dumpTitle), + quote: quoteOf(commentBody(d.commentId)), }; - case "playlist_followed": + } + case "playlist_followed": { + const d = data as PlaylistFollowedData; return { kind: "playlistFollow", - who: data.followerUsername, - target: playlistTarget(data, data.playlistTitle), + who: d.followerUsername, + target: playlistTarget(d, d.playlistTitle), }; + } case "user_followed": - return { kind: "userFollow", who: data.followerUsername }; - case "comment_liked": + return { + kind: "userFollow", + who: (data as UserFollowedData).followerUsername, + }; + case "comment_liked": { + const d = data as CommentLikedData; return { kind: "commentLike", - who: data.likerUsername, - target: dumpTarget(data, data.dumpTitle), - quote: quoteOf(commentBody(data.commentId)), + who: d.likerUsername, + target: dumpTarget(d, d.dumpTitle), + quote: quoteOf(commentBody(d.commentId)), }; + } } return null; } diff --git a/eslint.config.ts b/eslint.config.ts index 25342aea..5a346a74 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -6,7 +6,7 @@ import tseslint from "typescript-eslint"; import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(["dist", "locales"]), + globalIgnores(["dist", "locales", "coverage"]), { files: ["**/*.{ts,tsx}"], extends: [ diff --git a/src/App.css b/src/App.css index 9ea3dc2b..f30fe1ab 100644 --- a/src/App.css +++ b/src/App.css @@ -1132,6 +1132,7 @@ /* ── Custom audio player ── */ .audio-player { + container-type: inline-size; display: flex; align-items: center; flex-wrap: nowrap; @@ -1323,6 +1324,14 @@ display: block; } +/* A narrow bar keeps the mute button but drops the volume slider, which took + as much room as the waveform (a phone's volume is in its buttons anyway). */ +@container (max-width: 420px) { + .audio-player-track--volume { + display: none; + } +} + .file-preview-pdf { width: 100%; height: 80vh; @@ -1542,6 +1551,19 @@ a.global-player-title:hover { opacity: 0.35; cursor: default; } +/* On a phone the controls would leave the title a few letters, so they take a + row of their own under it. */ +@media (max-width: 600px) { + .global-player-header { + flex-wrap: wrap; + row-gap: 0.25rem; + } + .global-player-transport { + order: 1; + width: 100%; + justify-content: center; + } +} .global-player-status { margin: 0; padding: 0 1rem 0.5rem; @@ -1627,9 +1649,13 @@ body.has-player { moves to the top-right while the global player is mounted (it owns the bottom of the screen, full-width on mobile). Sits below the player and modal backdrop (both z-index 1000). */ +body { + /* Both floating buttons' size, which the page's end clearance reads too. */ + --fab-size: 3.5rem; +} + .dump-fab { position: fixed; - --fab-size: 3.5rem; /* Sit just outside the right edge of the centered content lane — in the margin beside it — rather than over the content or pinned to the viewport edge. The lane width is `--fab-lane` (860px for most feeds; the journal @@ -1705,10 +1731,11 @@ body.has-player .dump-fab { top: calc(1.25rem + env(safe-area-inset-top, 0px)); } -/* Reserve room at the end of the scroll so the button never hides the last - content when scrolled all the way down. */ +/* Reserve room at the end of the scroll so the buttons never hide the last + content when scrolled all the way down: the chat button stacks on the "+", + so the pair reaches two buttons and their gaps (2rem) up from the bottom. */ body.has-fab .page-content { - padding-bottom: 5rem; + padding-bottom: calc(var(--fab-size) * 2 + 3rem); } /* Chat FAB — mirrors .dump-fab and stacks directly above it (the "+" dump @@ -1716,7 +1743,6 @@ body.has-fab .page-content { never overlap. */ .chat-fab { position: fixed; - --fab-size: 3.5rem; /* Share the dump-fab's column. */ right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem)); @@ -2123,37 +2149,24 @@ body.has-player .chat-fab { } .profile-info { - position: relative; + flex: 1; min-width: 0; + /* A long name or email breaks rather than running past a phone's edge. */ + overflow-wrap: anywhere; } -.profile-info-scroll { - overflow-x: auto; - scrollbar-width: none; - padding-bottom: 0.25rem; - padding-right: 0.25rem; -} - -.profile-info-scroll::-webkit-scrollbar { - display: none; -} - -/* Fade hint only at narrow viewports where the info column can actually overflow */ @media (max-width: 600px) { - .profile-info::after { - content: ""; - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 2rem; - background: linear-gradient(to right, transparent, var(--color-bg)); - pointer-events: none; + .profile-header { + gap: 1rem; + } + /* zoom shrinks the layout box too, which Avatar's inline size pins at 128px, + so the info column keeps room for its buttons. */ + .profile-avatar-wrapper { + zoom: 0.7; } } .profile-tabs-scroller { - margin-top: 0.5rem; padding-bottom: 0.5rem; border-bottom: var(--border-width) solid var(--color-border); } @@ -2343,9 +2356,9 @@ body.has-player .chat-fab { } /* ── Profile description ── */ +/* No margins of its own: the page's gap already sets it apart. */ .profile-description { width: 100%; - margin-bottom: 1.5rem; } .profile-description-view { @@ -2399,6 +2412,7 @@ body.has-player .chat-fab { .profile-email-display { display: inline-flex; + max-width: 100%; align-items: center; gap: 0.35rem; font-size: var(--text-xs); @@ -2545,11 +2559,7 @@ body.has-player .chat-fab { cursor: progress; } -/* Raised above the info column's narrow-viewport overflow fade - (.profile-info::after) so the Copy button isn't washed out. */ .invite-panel { - position: relative; - z-index: 1; width: 0; min-width: 100%; box-sizing: border-box; @@ -3289,7 +3299,9 @@ body.has-player .chat-fab { .form-page { display: grid; - grid-template-columns: 1fr; + /* minmax(0, …): a 1fr track grows to its widest unbreakable content, so a + long URL or button row would push the page past a phone's edge. */ + grid-template-columns: minmax(0, 1fr); grid-template-rows: auto; gap: 3px; animation: page-enter 0.2s ease both; @@ -3301,14 +3313,14 @@ body.has-player .chat-fab { columns end on the same line. Only the header and footer are rounded. */ .dump-edit-form { display: grid; - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); gap: inherit; } @media (min-width: 860px) { .form-page--two-col, .dump-edit-form { - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } .form-page--two-col .form-page-header, .dump-edit-form, @@ -3403,6 +3415,9 @@ body.has-player .chat-fab { .form-actions { display: flex; + /* A destructive action, Cancel and Save don't fit a phone side by side in + every face; the right group then drops to its own line, still right. */ + flex-wrap: wrap; align-items: center; justify-content: space-between; padding-top: 0.75rem; @@ -3631,15 +3646,23 @@ body.has-player .chat-fab { /* ── Feed sort scroller + buttons ── */ -/* Wrapper: constrains width, clips scroll overflow, and carries the gradient hint */ +/* Wrapper: constrains width, clips scroll overflow, and carries the gradient + hint. --tab-row-bg is what the row sits on, which the fade and the pinned + leading content paint so the tabs vanish under them. */ .feed-sort-scroller { position: relative; min-width: 0; overflow: hidden; + --tab-row-bg: var(--color-bg); } -/* Right-edge gradient: absolutely positioned over the scroll container, - always at the viewport-right edge regardless of scroll position */ +.app-header .feed-sort-scroller { + --tab-row-bg: var(--header-bg); +} + +/* Right-edge fade, over the scroll container whatever its scroll position. + It shows only while tabs are hidden past that edge (TabBar's + --more), so it never dims the last tab once it is scrolled to or fits. */ .feed-sort-scroller::after { content: ""; position: absolute; @@ -3647,19 +3670,66 @@ body.has-player .chat-fab { right: 0; bottom: 0; width: 2rem; - background: linear-gradient(to right, transparent, var(--color-bg)); + background: linear-gradient(to right, transparent, var(--tab-row-bg)); pointer-events: none; + opacity: 0; + transition: opacity var(--dur-base); } -/* In the app header the tabs only scroll as a last resort, and a permanent - fade would dim the last tab whenever they fit — so it shows only while they - overflow, in the header's own colour. */ -.app-header .feed-sort-scroller::after { - background: linear-gradient(to right, transparent, var(--header-bg)); +.feed-sort-scroller--more::after { + opacity: 1; } -.app-header .feed-sort-scroller:not(.feed-sort-scroller--overflowing)::after { - display: none; +/* Its twin on the left edge once the row has scrolled, unless leading content + is pinned there, which draws its own (.feed-sort-leading::after). */ +.feed-sort-scroller::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 1; + width: 2rem; + background: linear-gradient(to left, transparent, var(--tab-row-bg)); + pointer-events: none; + opacity: 0; + transition: opacity var(--dur-base); +} + +.feed-sort-scroller--more-left:not(:has(.feed-sort-leading))::before { + opacity: 1; +} + +/* The leading content (the scope switcher) stays at the row's start while the + tabs scroll under it. Its padding hides them right after its divider, and + the matching negative margin keeps the gap to the first tab unchanged. */ +.feed-sort-leading { + position: sticky; + left: 0; + z-index: 1; + display: flex; + flex-shrink: 0; + padding-right: 0.4rem; + margin-right: -0.4rem; + background: var(--tab-row-bg); +} + +/* Once tabs have scrolled under it, a short fade softens their cut edge. */ +.feed-sort-leading::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 100%; + width: 0.75rem; + background: linear-gradient(to left, transparent, var(--tab-row-bg)); + pointer-events: none; + opacity: 0; + transition: opacity var(--dur-base); +} + +.feed-sort-scroller--more-left .feed-sort-leading::after { + opacity: 1; } /* In the index below-header the scroller should fill the available row width */ @@ -3712,6 +3782,13 @@ body.has-player .chat-fab { color: var(--tab-active-fg); } +/* Narrower pills on a phone, so more tabs fit before the row has to scroll. */ +@media (max-width: 600px) { + .feed-sort-btn { + padding-inline: 0.6rem; + } +} + /* ── Dump feed ── */ @keyframes card-enter { from { @@ -3764,8 +3841,11 @@ body.has-player .chat-fab { border-color: var(--card-hover-border); } -/* Card-specific layout */ +/* Card-specific layout. A container, since a card is often much narrower than + the viewport (a playlist's has a drag handle and a remove button beside it), + and its own width decides when the body has no room left beside the thumb. */ .dump-card { + container: dump-card / inline-size; display: grid; grid-template-rows: 1fr; transition: @@ -3821,47 +3901,11 @@ body.has-player .chat-fab { } @media (max-width: 512px) { - .dump-card-inner, .playlist-card-inner { gap: 0.5rem; padding: 0.6rem 0.75rem; } - /* Reset flex order and margin — narrow mode uses dump-card-left as a real flex container */ - .dump-card-body, - .dump-card-vote { - order: 0; - } - - /* Left column: preview + vote stacked with fixed gap, independent of body height */ - .dump-card-left { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.5rem; - flex-shrink: 0; - } - - .dump-card-body { - align-self: center; - } - - /* Slightly smaller 16:9 thumb on phones so the body keeps enough width. */ - .dump-card-preview { - width: 104px; - height: 58px; - } - - .dump-card-meta { - flex-direction: column; - gap: 0.1rem; - } - - .dump-card-comment { - display: none; - } - .playlist-card-description { display: none; } @@ -4183,6 +4227,100 @@ body.has-player .chat-fab { align-self: center; } +/* A narrow card (on a phone, or in a playlist, whose drag handle and remove + button narrow it further) is a grid that both columns' wrappers dissolve + into, so the vote ends the date line, as on the journal's cards, rather + than hanging under the thumb. The empty 1fr rows centre the text against + the thumb when it is the taller; the fixed row after the title keeps the + vote, which the title's last line can run over, clear of it. */ +@container dump-card (max-width: 472px) { + .dump-card-inner { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-rows: 1fr auto 0.4rem auto auto 1fr; + /* No row gap: the empty rows would add theirs; the spacer row and the + categories' margin set the space instead. */ + gap: 0 0.75rem; + padding: 0.6rem 0.75rem; + } + + .dump-card-left, + .dump-card-body { + display: contents; + } + + .dump-card-preview { + grid-column: 1; + grid-row: 1 / -1; + align-self: center; + /* Slightly smaller 16:9 thumb, so the text keeps enough width. */ + width: 104px; + height: 58px; + } + + .dump-card-title { + grid-column: 2 / -1; + grid-row: 2; + } + + .dump-card-comment { + display: none; + } + + .dump-card-meta { + grid-column: 2; + grid-row: 4; + align-self: center; + margin-top: 0; + /* Its parts wrap whole, so a date never breaks across lines. */ + flex-wrap: wrap; + row-gap: 0.1rem; + white-space: nowrap; + } + + .dump-card-vote { + grid-column: 3; + grid-row: 4; + } + + .dump-card-categories { + grid-column: 2 / -1; + grid-row: 5; + } +} + +/* Too narrow for any text beside the thumb, which left a title a few letters + per line: the thumb takes the top row and the text the full width under it, + the vote still ending the date line. */ +@container dump-card (max-width: 300px) { + .dump-card-inner { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto 0.4rem auto auto; + } + + .dump-card-preview { + grid-column: 1 / -1; + grid-row: 1; + margin-bottom: 0.5rem; + } + + .dump-card-title { + grid-column: 1 / -1; + } + + .dump-card-meta { + grid-column: 1; + } + + .dump-card-vote { + grid-column: 2; + } + + .dump-card-categories { + grid-column: 1 / -1; + } +} + @media (max-width: 512px) { .app-header-brand-name { display: none; diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx index 6b9a0650..966bef66 100644 --- a/src/components/profile/ProfileHeader.tsx +++ b/src/components/profile/ProfileHeader.tsx @@ -107,90 +107,88 @@ export function ProfileHeader( )}
-
-
-

{user.username}

- -
- {user.invitedByUsername +
+

{user.username}

+ +
+ {user.invitedByUsername + ? ( +

+ invited by{" "} + + @{user.invitedByUsername} + +

+ ) + : ( +

+ O.G. +

+ )} + {isOwnProfile && ( + emailEditing ? ( -

- invited by{" "} - - @{user.invitedByUsername} - -

+ setEmailEditing(false)} + onSaved={handleEmailSaved} + /> ) : ( -

- O.G. -

- )} - {isOwnProfile && ( - emailEditing - ? ( - setEmailEditing(false)} - onSaved={handleEmailSaved} - /> - ) - : ( -

setEmailEditing(true)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setEmailEditing(true); - } - }} - tabIndex={0} - role="button" - title={t`Edit email`} - > - {me?.email ?? t`Add email…`} - - ✎ - -

- ) - )} - {avatarError && ( - - )} - {!isOwnProfile && ( - - )} - {isOwnProfile && ( - - - Settings - - {can(me, "category:manage") && ( - - Manage - - )} - - - )} -
+ {me?.email ?? t`Add email…`} + + ✎ + +

+ ) + )} + {avatarError && ( + + )} + {!isOwnProfile && ( + + )} + {isOwnProfile && ( + + + Settings + + {can(me, "category:manage") && ( + + Manage + + )} + + + )}
); diff --git a/src/components/shared/TabBar.tsx b/src/components/shared/TabBar.tsx index a27073e7..7e051d2b 100644 --- a/src/components/shared/TabBar.tsx +++ b/src/components/shared/TabBar.tsx @@ -28,7 +28,8 @@ export interface TabBarProps { innerClassName?: string; /** * Optional content rendered inside the row, before the tabs (e.g. a scope - * selector). Sits in the same flex line so it scrolls and aligns with them. + * selector). Sits in the same flex line, aligned with them, and stays pinned + * at the row's start while they scroll under it. */ leading?: ReactNode; } @@ -42,23 +43,35 @@ export function TabBar( TabBarProps, ): React.JSX.Element { const rowRef = useRef(null); + const leadingRef = useRef(null); const [overflowing, setOverflowing] = useState(false); + // Tabs hidden past either edge, which the stylesheet fades only then, so a + // fade never dims a tab that is fully in view. + const [moreLeft, setMoreLeft] = useState(false); + const [moreRight, setMoreRight] = useState(false); - // Flags a row whose tabs don't all fit, for places that fade the edge only - // then (the app header) rather than always. useLayoutEffect(() => { const row = rowRef.current; if (!row) return; - const measure = () => setOverflowing(row.scrollWidth > row.clientWidth); + const measure = () => { + setOverflowing(row.scrollWidth > row.clientWidth); + setMoreLeft(row.scrollLeft > 1); + setMoreRight(row.scrollLeft + row.clientWidth < row.scrollWidth - 1); + }; const resize = new ResizeObserver(measure); resize.observe(row); for (const child of row.children) resize.observe(child); + row.addEventListener("scroll", measure, { passive: true }); measure(); - return () => resize.disconnect(); + return () => { + resize.disconnect(); + row.removeEventListener("scroll", measure); + }; }, [tabs.length]); - // When they don't all fit, keep the active tab in view, clear of the fade - // over the row's right edge — scrolling the row alone, never the page. + // When they don't all fit, keep the active tab in view, between the leading + // content pinned over the row's left edge and the fade over its right edge, + // scrolling the row alone, never the page. useLayoutEffect(() => { const row = rowRef.current; const active = row?.querySelector(".feed-sort-btn.active"); @@ -67,7 +80,8 @@ export function TabBar( const fade = parseFloat(getComputedStyle(document.documentElement).fontSize) * 2; const r = row.getBoundingClientRect(), b = active.getBoundingClientRect(); - if (b.left < r.left) row.scrollLeft -= r.left - b.left; + const left = r.left + (leadingRef.current?.offsetWidth ?? 0); + if (b.left < left) row.scrollLeft -= left - b.left; else if (b.right > r.right - fade) { row.scrollLeft += b.right - r.right + fade; } @@ -76,14 +90,18 @@ export function TabBar( return (
- {leading} + {leading && ( +
{leading}
+ )} {tabs.map(({ key, label }) => (