v4: the avatar menu shows at every width and holds Log out, so a profile keeps only Invite; the profile's dump cards lay out again — your own profile had four buttons in a row, Invite someone, Settings, Manage and Log out, Invite taller than the rest, and on wide screens it was the only way to reach /settings or /manage. The avatar menu now leads the header's icon row at every width, after the name and Playlists text links on wide screens, and ends with Log out under a separator, muted until hovered. The profile keeps Invite someone alone. The menu's button outweighs .app-header-nav button and the themes' button rules (Brutalist and GeoCities list it among their flat buttons), so it reads as one of the menu's links. Since b9ff9c4, a card under 472px is laid out by the narrow-card container query, but the profile columns' dense-card rules still placed the thumb, body and vote themselves, more specifically: the thumb lost its row span, so the title fell under it and every card was a tall, mostly empty box. Those rules now only make the card denser (a smaller thumb, a 2-line title, smaller type) and leave placement to the container query. 670 frontend tests pass, and tsc, eslint, deno fmt, doc:lint and i18n:compile --strict are clean; checked in screenshots in Smooth dark at 1400 and 390px, Brutalist light at 390px, GeoCities light and Kanagawa dark at 1400px, in French.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-08 22:22:15 +00:00
parent 10c9cf4269
commit 886216749e
11 changed files with 179 additions and 219 deletions

View File

@@ -431,7 +431,7 @@ msgstr "← Back"
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
#: src/components/profile/ProfileOwnActions.tsx:80
#: src/components/profile/ProfileOwnActions.tsx:76
msgid "+ Invite someone"
msgstr "+ Invite someone"
@@ -518,11 +518,11 @@ msgstr "Add a comment…"
msgid "Add category"
msgstr "Add category"
#: src/components/profile/ProfileHeader.tsx:154
#: src/components/profile/ProfileHeader.tsx:153
msgid "Add email…"
msgstr "Add email…"
#: src/components/shared/ImagePicker.tsx:63
#: src/components/shared/ImagePicker.tsx:66
msgid "Add image"
msgstr "Add image"
@@ -589,7 +589,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:752
#: src/components/form/FormActions.tsx:42
#: src/components/playlist/PlaylistEditForm.tsx:199
#: src/components/playlist/PlaylistEditForm.tsx:200
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
@@ -607,7 +607,7 @@ msgstr "Cancel removal"
msgid "Cancel reply"
msgstr "Cancel reply"
#: src/components/layout/AppHeader.tsx:190
#: src/components/layout/AppHeader.tsx:188
msgid "Cancel search"
msgstr "Cancel search"
@@ -620,11 +620,11 @@ msgstr "Categories"
msgid "Category"
msgstr "Category"
#: src/components/profile/ProfileHeader.tsx:96
#: src/components/profile/ProfileHeader.tsx:95
msgid "Change avatar"
msgstr "Change avatar"
#: src/components/shared/ImagePicker.tsx:63
#: src/components/shared/ImagePicker.tsx:66
msgid "Change image"
msgstr "Change image"
@@ -695,11 +695,11 @@ msgstr "Confirm new password"
msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent"
#: src/components/profile/ProfileOwnActions.tsx:105
#: src/components/profile/ProfileOwnActions.tsx:99
msgid "Copied!"
msgstr "Copied!"
#: src/components/profile/ProfileOwnActions.tsx:105
#: src/components/profile/ProfileOwnActions.tsx:99
msgid "Copy"
msgstr "Copy"
@@ -800,7 +800,7 @@ msgid "Delete failed ({0})"
msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:142
#: src/components/playlist/PlaylistEditForm.tsx:190
#: src/components/playlist/PlaylistEditForm.tsx:191
#: src/components/profile/ProfilePlaylistsTab.tsx:156
#: src/pages/PlaylistDetail.tsx:141
msgid "Delete playlist"
@@ -827,7 +827,7 @@ msgstr "Delete this playlist? This cannot be undone."
msgid "deleted message"
msgstr "deleted message"
#: src/components/playlist/PlaylistEditForm.tsx:175
#: src/components/playlist/PlaylistEditForm.tsx:176
msgid "Description"
msgstr "Description"
@@ -869,7 +869,7 @@ msgstr "Drop a file here, or paste one"
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
#: src/components/layout/AppHeader.tsx:209
#: src/components/layout/AppHeader.tsx:207
msgid "Dump"
msgstr "Dump"
@@ -914,7 +914,7 @@ msgstr "Edit {0}"
msgid "Edit description"
msgstr "Edit description"
#: src/components/profile/ProfileHeader.tsx:152
#: src/components/profile/ProfileHeader.tsx:151
msgid "Edit email"
msgstr "Edit email"
@@ -984,9 +984,9 @@ msgstr "Failed to create playlist"
msgid "Failed to delete"
msgstr "Failed to delete"
#: src/components/profile/ProfileOwnActions.tsx:46
#: src/components/profile/ProfileOwnActions.tsx:49
#: src/components/profile/ProfileOwnActions.tsx:52
#: src/components/profile/ProfileOwnActions.tsx:114
#: src/components/profile/ProfileOwnActions.tsx:108
msgid "Failed to generate invite"
msgstr "Failed to generate invite"
@@ -1006,7 +1006,7 @@ msgstr "Failed to post comment"
msgid "Failed to post reply"
msgstr "Failed to post reply"
#: src/components/playlist/PlaylistEditForm.tsx:184
#: src/components/playlist/PlaylistEditForm.tsx:185
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save"
@@ -1020,7 +1020,7 @@ msgstr "Failed to save category"
msgid "Failed to save edit"
msgstr "Failed to save edit"
#: src/components/profile/ProfileHeader.tsx:163
#: src/components/profile/ProfileHeader.tsx:162
msgid "Failed to update avatar"
msgstr "Failed to update avatar"
@@ -1128,12 +1128,12 @@ msgstr "Invalid invite"
msgid "Invalid link"
msgstr "Invalid link"
#: src/components/profile/ProfileOwnActions.tsx:88
#: src/components/profile/ProfileOwnActions.tsx:95
#: src/components/profile/ProfileOwnActions.tsx:82
#: src/components/profile/ProfileOwnActions.tsx:89
msgid "Invite link"
msgstr "Invite link"
#: src/components/profile/ProfileHeader.tsx:117
#: src/components/profile/ProfileHeader.tsx:116
msgid "invited by"
msgstr "invited by"
@@ -1176,7 +1176,7 @@ msgstr "Link or file"
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
#: src/components/layout/AppHeader.tsx:242
#: src/components/layout/AppHeader.tsx:240
msgid "Live updates unavailable."
msgstr "Live updates unavailable."
@@ -1219,7 +1219,7 @@ msgstr "Loading profile…"
msgid "Loading…"
msgstr "Loading…"
#: src/components/layout/AppHeader.tsx:216
#: src/components/layout/AppHeader.tsx:214
#: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:67
#: src/pages/UserLogin.tsx:86
@@ -1234,7 +1234,7 @@ msgstr "Log in to like"
msgid "Log in to vote"
msgstr "Log in to vote"
#: src/components/profile/ProfileHeader.tsx:188
#: src/components/layout/UserMenu.tsx:115
#: src/pages/UserPublicProfile.tsx:131
msgid "Log out"
msgstr "Log out"
@@ -1251,8 +1251,7 @@ msgstr "Login failed"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:98
#: src/components/profile/ProfileHeader.tsx:180
#: src/components/layout/UserMenu.tsx:102
#: src/pages/Manage.tsx:24
#: src/pages/Manage.tsx:29
#: src/pages/UserPublicProfile.tsx:164
@@ -1465,7 +1464,7 @@ msgstr "Play"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:178
#: src/components/layout/UserMenu.tsx:81
#: src/components/layout/UserMenu.tsx:85
#: src/components/profile/ProfileFollowTab.tsx:167
#: src/components/profile/ProfilePlaylistsTab.tsx:118
#: src/pages/Search.tsx:123
@@ -1616,7 +1615,7 @@ msgstr "Role"
#: src/components/category/CategoryManager.tsx:216
#: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216
#: src/components/playlist/PlaylistEditForm.tsx:206
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:437
@@ -1625,7 +1624,7 @@ msgid "Save"
msgstr "Save"
#: src/components/comment/CommentNode.tsx:217
#: src/components/playlist/PlaylistEditForm.tsx:203
#: src/components/playlist/PlaylistEditForm.tsx:204
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
@@ -1634,7 +1633,7 @@ msgstr "Save"
msgid "Saving…"
msgstr "Saving…"
#: src/components/layout/AppHeader.tsx:190
#: src/components/layout/AppHeader.tsx:188
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:69
msgid "Search"
@@ -1677,7 +1676,7 @@ msgstr "Send reset link"
msgid "Sending…"
msgstr "Sending…"
#: src/components/layout/AppHeader.tsx:206
#: src/components/layout/AppHeader.tsx:204
msgid "Server unreachable"
msgstr "Server unreachable"
@@ -1686,8 +1685,7 @@ msgstr "Server unreachable"
msgid "Set new password"
msgstr "Set new password"
#: src/components/layout/UserMenu.tsx:89
#: src/components/profile/ProfileHeader.tsx:176
#: src/components/layout/UserMenu.tsx:93
#: src/pages/Settings.tsx:70
#: src/pages/Settings.tsx:82
msgid "Settings"
@@ -1697,7 +1695,7 @@ msgstr "Settings"
msgid "Several people are typing…"
msgstr "Several people are typing…"
#: src/components/profile/ProfileOwnActions.tsx:108
#: src/components/profile/ProfileOwnActions.tsx:102
msgid "Single use, valid for 7 days."
msgstr "Single use, valid for 7 days."
@@ -1786,7 +1784,7 @@ msgstr "Thumbnail"
#: src/components/dump/create/TitleField.tsx:45
#: src/components/playlist/PlaylistCreateForm.tsx:96
#: src/components/playlist/PlaylistEditForm.tsx:159
#: src/components/playlist/PlaylistEditForm.tsx:160
#: src/pages/DumpEdit.tsx:516
msgid "Title"
msgstr "Title"
@@ -1853,7 +1851,7 @@ msgstr "Unsubscribe"
msgid "Unsubscribed"
msgstr "Unsubscribed"
#: src/components/profile/ProfileHeader.tsx:73
#: src/components/profile/ProfileHeader.tsx:72
msgid "Upload failed"
msgstr "Upload failed"
@@ -1904,7 +1902,7 @@ msgstr "User"
msgid "User management"
msgstr "User management"
#: src/components/layout/UserMenu.tsx:56
#: src/components/layout/UserMenu.tsx:60
msgid "User menu"
msgstr "User menu"

View File

@@ -431,7 +431,7 @@ msgstr "← Retour"
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
#: src/components/profile/ProfileOwnActions.tsx:80
#: src/components/profile/ProfileOwnActions.tsx:76
msgid "+ Invite someone"
msgstr "+ Inviter quelqu'un"
@@ -518,11 +518,11 @@ msgstr "Ajouter un commentaire…"
msgid "Add category"
msgstr "Ajouter une catégorie"
#: src/components/profile/ProfileHeader.tsx:154
#: src/components/profile/ProfileHeader.tsx:153
msgid "Add email…"
msgstr "Ajouter un e-mail…"
#: src/components/shared/ImagePicker.tsx:63
#: src/components/shared/ImagePicker.tsx:66
msgid "Add image"
msgstr "Ajouter une image"
@@ -589,7 +589,7 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:752
#: src/components/form/FormActions.tsx:42
#: src/components/playlist/PlaylistEditForm.tsx:199
#: src/components/playlist/PlaylistEditForm.tsx:200
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
@@ -607,7 +607,7 @@ msgstr "Annuler la suppression"
msgid "Cancel reply"
msgstr "Annuler la réponse"
#: src/components/layout/AppHeader.tsx:190
#: src/components/layout/AppHeader.tsx:188
msgid "Cancel search"
msgstr "Annuler la recherche"
@@ -620,11 +620,11 @@ msgstr "Catégories"
msgid "Category"
msgstr "Catégorie"
#: src/components/profile/ProfileHeader.tsx:96
#: src/components/profile/ProfileHeader.tsx:95
msgid "Change avatar"
msgstr "Changer l'avatar"
#: src/components/shared/ImagePicker.tsx:63
#: src/components/shared/ImagePicker.tsx:66
msgid "Change image"
msgstr "Changer l'image"
@@ -695,11 +695,11 @@ msgstr "Confirmer le nouveau mot de passe"
msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
#: src/components/profile/ProfileOwnActions.tsx:105
#: src/components/profile/ProfileOwnActions.tsx:99
msgid "Copied!"
msgstr "Copié !"
#: src/components/profile/ProfileOwnActions.tsx:105
#: src/components/profile/ProfileOwnActions.tsx:99
msgid "Copy"
msgstr "Copier"
@@ -800,7 +800,7 @@ msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:142
#: src/components/playlist/PlaylistEditForm.tsx:190
#: src/components/playlist/PlaylistEditForm.tsx:191
#: src/components/profile/ProfilePlaylistsTab.tsx:156
#: src/pages/PlaylistDetail.tsx:141
msgid "Delete playlist"
@@ -827,7 +827,7 @@ msgstr "Supprimer cette collection ? Cette action est irréversible."
msgid "deleted message"
msgstr "message supprimé"
#: src/components/playlist/PlaylistEditForm.tsx:175
#: src/components/playlist/PlaylistEditForm.tsx:176
msgid "Description"
msgstr "Description"
@@ -869,7 +869,7 @@ msgstr "Déposez un fichier ici, ou collez-en un"
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
#: src/components/layout/AppHeader.tsx:209
#: src/components/layout/AppHeader.tsx:207
msgid "Dump"
msgstr "Reco"
@@ -914,7 +914,7 @@ msgstr "Modifier {0}"
msgid "Edit description"
msgstr "Modifier la description"
#: src/components/profile/ProfileHeader.tsx:152
#: src/components/profile/ProfileHeader.tsx:151
msgid "Edit email"
msgstr "Modifier l'e-mail"
@@ -984,9 +984,9 @@ msgstr "Impossible de créer la collection"
msgid "Failed to delete"
msgstr "Suppression échouée"
#: src/components/profile/ProfileOwnActions.tsx:46
#: src/components/profile/ProfileOwnActions.tsx:49
#: src/components/profile/ProfileOwnActions.tsx:52
#: src/components/profile/ProfileOwnActions.tsx:114
#: src/components/profile/ProfileOwnActions.tsx:108
msgid "Failed to generate invite"
msgstr "Impossible de générer une invitation"
@@ -1006,7 +1006,7 @@ msgstr "Impossible de publier le commentaire"
msgid "Failed to post reply"
msgstr "Impossible de publier la réponse"
#: src/components/playlist/PlaylistEditForm.tsx:184
#: src/components/playlist/PlaylistEditForm.tsx:185
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save"
@@ -1020,7 +1020,7 @@ msgstr "Échec de l’enregistrement de la catégorie"
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
#: src/components/profile/ProfileHeader.tsx:163
#: src/components/profile/ProfileHeader.tsx:162
msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar"
@@ -1128,12 +1128,12 @@ msgstr "Invitation invalide"
msgid "Invalid link"
msgstr "Lien invalide"
#: src/components/profile/ProfileOwnActions.tsx:88
#: src/components/profile/ProfileOwnActions.tsx:95
#: src/components/profile/ProfileOwnActions.tsx:82
#: src/components/profile/ProfileOwnActions.tsx:89
msgid "Invite link"
msgstr "Lien d'invitation"
#: src/components/profile/ProfileHeader.tsx:117
#: src/components/profile/ProfileHeader.tsx:116
msgid "invited by"
msgstr "invité par"
@@ -1176,7 +1176,7 @@ msgstr "Lien ou fichier"
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
#: src/components/layout/AppHeader.tsx:242
#: src/components/layout/AppHeader.tsx:240
msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles."
@@ -1219,7 +1219,7 @@ msgstr "Chargement du profil…"
msgid "Loading…"
msgstr "Chargement…"
#: src/components/layout/AppHeader.tsx:216
#: src/components/layout/AppHeader.tsx:214
#: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:67
#: src/pages/UserLogin.tsx:86
@@ -1234,7 +1234,7 @@ msgstr "Se connecter pour aimer"
msgid "Log in to vote"
msgstr "Se connecter pour voter"
#: src/components/profile/ProfileHeader.tsx:188
#: src/components/layout/UserMenu.tsx:115
#: src/pages/UserPublicProfile.tsx:131
msgid "Log out"
msgstr "Se déconnecter"
@@ -1251,8 +1251,7 @@ msgstr "Connexion échouée"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:98
#: src/components/profile/ProfileHeader.tsx:180
#: src/components/layout/UserMenu.tsx:102
#: src/pages/Manage.tsx:24
#: src/pages/Manage.tsx:29
#: src/pages/UserPublicProfile.tsx:164
@@ -1465,7 +1464,7 @@ msgstr "Lecture"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:178
#: src/components/layout/UserMenu.tsx:81
#: src/components/layout/UserMenu.tsx:85
#: src/components/profile/ProfileFollowTab.tsx:167
#: src/components/profile/ProfilePlaylistsTab.tsx:118
#: src/pages/Search.tsx:123
@@ -1616,7 +1615,7 @@ msgstr "Rôle"
#: src/components/category/CategoryManager.tsx:216
#: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216
#: src/components/playlist/PlaylistEditForm.tsx:206
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:437
@@ -1625,7 +1624,7 @@ msgid "Save"
msgstr "Enregistrer"
#: src/components/comment/CommentNode.tsx:217
#: src/components/playlist/PlaylistEditForm.tsx:203
#: src/components/playlist/PlaylistEditForm.tsx:204
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
@@ -1634,7 +1633,7 @@ msgstr "Enregistrer"
msgid "Saving…"
msgstr "Enregistrement…"
#: src/components/layout/AppHeader.tsx:190
#: src/components/layout/AppHeader.tsx:188
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:69
msgid "Search"
@@ -1677,7 +1676,7 @@ msgstr "Envoyer le lien de réinitialisation"
msgid "Sending…"
msgstr "Envoi…"
#: src/components/layout/AppHeader.tsx:206
#: src/components/layout/AppHeader.tsx:204
msgid "Server unreachable"
msgstr "Serveur inaccessible"
@@ -1686,8 +1685,7 @@ msgstr "Serveur inaccessible"
msgid "Set new password"
msgstr "Définir un nouveau mot de passe"
#: src/components/layout/UserMenu.tsx:89
#: src/components/profile/ProfileHeader.tsx:176
#: src/components/layout/UserMenu.tsx:93
#: src/pages/Settings.tsx:70
#: src/pages/Settings.tsx:82
msgid "Settings"
@@ -1697,7 +1695,7 @@ msgstr "Paramètres"
msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…"
#: src/components/profile/ProfileOwnActions.tsx:108
#: src/components/profile/ProfileOwnActions.tsx:102
msgid "Single use, valid for 7 days."
msgstr "Usage unique, valable 7 jours."
@@ -1786,7 +1784,7 @@ msgstr "Miniature"
#: src/components/dump/create/TitleField.tsx:45
#: src/components/playlist/PlaylistCreateForm.tsx:96
#: src/components/playlist/PlaylistEditForm.tsx:159
#: src/components/playlist/PlaylistEditForm.tsx:160
#: src/pages/DumpEdit.tsx:516
msgid "Title"
msgstr "Titre"
@@ -1853,7 +1851,7 @@ msgstr "Se désabonner"
msgid "Unsubscribed"
msgstr "Désabonné"
#: src/components/profile/ProfileHeader.tsx:73
#: src/components/profile/ProfileHeader.tsx:72
msgid "Upload failed"
msgstr "Envoi échoué"
@@ -1904,7 +1902,7 @@ msgstr "Utilisateur"
msgid "User management"
msgstr "Gestion utilisateur"
#: src/components/layout/UserMenu.tsx:56
#: src/components/layout/UserMenu.tsx:60
msgid "User menu"
msgstr "Menu utilisateur"

View File

@@ -2186,54 +2186,28 @@ body.has-player .chat-fab {
}
/* ── Dense dump cards in the two-column profile view ──
The default card is proportioned for the 860px feed. Dropped into a ~400px
column it falls apart: the title wraps four lines deep, the meta row stacks,
every category chip takes a line of its own and the vote button floats in
the middle of the resulting void. Below is the same card re-proportioned for
the column — smaller thumb, title clamped to two lines, no teaser, and meta
plus chips sharing the line under the title. Scoped to the width where the
columns actually split; narrower than that the feed is full-width again and
the regular (then the phone) layout applies. */
A ~400px column is narrow enough for the narrow card's grid (the
`@container dump-card` rules), which places the thumb, the text and the
vote; placing them here as well, more specifically, broke that grid. This
only makes the card denser for a column of a dozen: a smaller thumb, the
title clamped to two lines and smaller type. Scoped to the width where the
columns split; narrower than that the feed is full-width again. */
@media (min-width: 900px) {
.profile-columns .dump-feed {
padding: 0.75rem 0 0;
gap: 0.5rem;
}
/* preview | body | vote, all top-aligned so the count sits on the title
line instead of drifting to the card's vertical middle. */
.profile-columns .dump-card-inner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 0.55rem;
padding: 0.5rem 0.6rem;
}
.profile-columns .dump-card-preview {
grid-area: 1 / 1;
width: 88px;
height: 50px;
}
.profile-columns .dump-card-vote {
grid-area: 1 / 3;
align-self: start;
}
/* Row layout with a full-width title: the title takes a line to itself, then
meta and chips flow onto the next one and only wrap when they must. */
.profile-columns .dump-card-body {
grid-area: 1 / 2;
margin-left: 0;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 0.15rem 0.45rem;
}
.profile-columns .dump-card-title {
flex: 1 0 100%;
font-size: var(--text-md);
line-height: 1.3;
display: -webkit-box;
@@ -2243,22 +2217,6 @@ body.has-player .chat-fab {
overflow: hidden;
}
/* The teaser is the first thing to go, the same call the phone layout makes. */
.profile-columns .dump-card-comment {
display: none;
}
.profile-columns .dump-card-meta,
.profile-columns .dump-card-categories {
margin-top: 0;
}
.profile-columns .dump-card-meta {
gap: 0.4rem;
/* Keeps "3 weeks ago" and "1 comment" on one line each. */
white-space: nowrap;
}
.profile-columns .dump-card-date,
.profile-columns .dump-card-comment-count {
font-size: var(--text-2xs);
@@ -2341,10 +2299,6 @@ body.has-player .chat-fab {
background: var(--color-success);
}
.profile-header .btn-border {
margin-top: 0.5rem;
}
.profile-header .follow-btn {
margin-top: 0.5rem;
}
@@ -2476,8 +2430,8 @@ body.has-player .chat-fab {
color: var(--color-accent);
}
/* Grid sized by the button row; the invite panel below only fills that
width (width: 0 + min-width: 100% keeps it out of the sizing). */
/* Grid sized by the button; the invite panel below only fills that width
(width: 0 + min-width: 100% keeps it out of the sizing). */
.profile-own-actions {
display: grid;
width: fit-content;
@@ -2485,15 +2439,6 @@ body.has-player .chat-fab {
gap: 0.5rem;
margin-top: 0.5rem;
}
.profile-own-actions-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.profile-own-actions .btn-border {
margin-top: 0;
}
.invite-btn {
padding: 0.3rem 0.9rem;
@@ -3121,18 +3066,6 @@ body.has-player .chat-fab {
}
}
/* Avatar menu — visible only below the text-links breakpoint */
.nav-compact {
display: flex;
align-items: center;
}
@media (min-width: 1150px) {
.nav-compact {
display: none;
}
}
.app-header-user {
display: inline-flex;
align-items: center;
@@ -3150,7 +3083,7 @@ body.has-player .chat-fab {
background: var(--color-header-user-bg-hover);
}
/* ── User menu (compact nav) ── */
/* ── User menu ── */
/* A flex box, so the inline-flex trigger leaves no line-box gap under it
that would sit it higher than the buttons beside it. */
.user-menu {
@@ -3206,6 +3139,7 @@ body.has-player .chat-fab {
color: var(--color-text);
font-size: var(--text-md);
font-weight: 600;
white-space: nowrap;
transition: background var(--dur-fast);
}
@@ -3213,6 +3147,33 @@ body.has-player .chat-fab {
background: var(--color-header-user-bg-hover);
}
.user-menu-separator {
margin: 0.15rem 0.35rem;
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* Log out is a button among links: it drops the button's own border,
background and weight, and is muted until hovered, as ending the session
should be. Two classes, to outweigh `.app-header-nav button`, which
centres it, and the themes' `[data-style] button`. */
.user-menu .user-menu-item--logout {
display: block;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
font-family: inherit;
font-size: var(--text-md);
font-weight: 600;
text-align: left;
line-height: inherit;
}
.user-menu .user-menu-item--logout:hover {
background: var(--color-header-user-bg-hover);
color: var(--color-danger);
}
.user-list-popover {
/* position/top/bottom/left/max-height set inline — anchored to the
trigger's viewport rect, flipping above it when there isn't room below */

View File

@@ -159,8 +159,8 @@ export function AppHeader(
<nav className="app-header-nav">
{
/* The user's own links lead the row, so the icons after them run
unbroken to the "+". CSS shows the text links or the avatar's
menu, by width. */
unbroken to the "+": the text links on wide screens, then the
avatar's menu at every width. */
}
{user && (
<>
@@ -178,9 +178,7 @@ export function AppHeader(
<Trans>Playlists</Trans>
</Link>
</div>
<div className="nav-compact">
<UserMenu user={user} />
</div>
<UserMenu user={user} />
</>
)}
<button

View File

@@ -1,6 +1,7 @@
/**
* Renders the avatar menu that replaces the header's profile and playlists
* links on small screens, and leads to the settings and the site's tools.
* Renders the avatar menu that leads the header's icon row at every width: it
* holds the profile and playlists links (on small screens, the only ones), the
* settings, the site's tools, and logging out.
*
* @module
*/
@@ -13,6 +14,7 @@ import { Avatar } from "../shared/Avatar.tsx";
import type { SelfUser } from "../../../shared/user.ts";
import { can } from "../../utils/auth/permissions.ts";
import { userPath } from "../../../shared/paths.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
/** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */
export interface UserMenuProps {
@@ -21,10 +23,12 @@ export interface UserMenuProps {
/**
* Renders the signed-in user's avatar, which opens a menu with links to their
* profile, their playlists and `/settings`, and to `/manage` for those who
* hold `category:manage`. A click outside or Escape closes it.
* profile, their playlists and `/settings`, to `/manage` for those who hold
* `category:manage`, and a Log out button. A click outside or Escape closes
* it.
*/
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
const { logout } = useAuth();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -98,6 +102,18 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
<Trans>Manage</Trans>
</Link>
)}
<div className="user-menu-separator" role="separator" />
<button
type="button"
className="user-menu-item user-menu-item--logout"
role="menuitem"
onClick={() => {
setOpen(false);
logout();
}}
>
<Trans>Log out</Trans>
</button>
</div>
)}
</div>

View File

@@ -7,7 +7,6 @@
import { useRef, useState } from "react";
import { Link } from "react-router";
import { can } from "../../utils/auth/permissions.ts";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
@@ -36,13 +35,13 @@ export interface ProfileHeaderProps {
/**
* Renders the avatar, username, role and inviter. Visitors get a follow
* button. On their own profile, users change their avatar
* (`PUT /api/me/avatar`) and email, invite someone, reach their settings (and
* the site's tools, with `category:manage`) and log out.
* (`PUT /api/me/avatar`) and email, and invite someone; their settings, the
* site's tools and logging out are in the header's avatar menu.
*/
export function ProfileHeader(
{ user, isOwnProfile, onUserChange }: ProfileHeaderProps,
): React.JSX.Element {
const { user: me, authFetch, updateSession, logout } = useAuth();
const { user: me, authFetch, updateSession } = useAuth();
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -170,25 +169,7 @@ export function ProfileHeader(
targetUsername={user.username}
/>
)}
{isOwnProfile && (
<ProfileOwnActions>
<Link to="/settings" className="btn-border">
<Trans>Settings</Trans>
</Link>
{can(me, "category:manage") && (
<Link to="/manage" className="btn-border">
<Trans>Manage</Trans>
</Link>
)}
<button
type="button"
className="btn-border btn-border--danger"
onClick={logout}
>
<Trans>Log out</Trans>
</button>
</ProfileOwnActions>
)}
{isOwnProfile && <ProfileOwnActions />}
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
/**
* Renders the actions of the signed-in user's own profile header, which invite
* someone, beside the log out button the header adds.
* Renders the action of the signed-in user's own profile header, which invites
* someone.
*
* @module
*/
@@ -14,13 +14,10 @@ import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { ErrorCard } from "../shared/ErrorCard.tsx";
/**
* Renders the "Invite someone" button, then `children`, the log out button.
* Each opening of the panel creates a new single-use invite link, for
* `/register`.
* Renders the "Invite someone" button, which opens a panel with an invite link.
* Each opening of the panel creates a new single-use link, for `/register`.
*/
export function ProfileOwnActions(
{ children }: { children: React.ReactNode },
): React.JSX.Element {
export function ProfileOwnActions(): React.JSX.Element {
const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [pending, setPending] = useState(false);
@@ -69,18 +66,15 @@ export function ProfileOwnActions(
return (
<div className="profile-own-actions" ref={ref}>
<div className="profile-own-actions-row">
<button
type="button"
className="invite-btn"
onClick={toggle}
disabled={pending}
aria-expanded={inviteUrl !== null}
>
<Trans>+ Invite someone</Trans>
</button>
{children}
</div>
<button
type="button"
className="invite-btn"
onClick={toggle}
disabled={pending}
aria-expanded={inviteUrl !== null}
>
<Trans>+ Invite someone</Trans>
</button>
{inviteUrl && (
<div
className="invite-panel animate-in"

View File

@@ -3,7 +3,8 @@ import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { UserMenu } from "../../../components/layout/UserMenu.tsx";
import { makeSession } from "../../fixtures.ts";
import { currentPath, renderWithContexts } from "../../render.tsx";
import { currentPath, renderApp, renderWithContexts } from "../../render.tsx";
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
describe("UserMenu", () => {
test("opens, links to profile and playlists, closes on Escape and outside", async () => {
@@ -41,4 +42,26 @@ describe("UserMenu", () => {
expect(currentPath()).toBe("/@ann");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
test("links to /manage for those who hold category:manage", async () => {
const user = userEvent.setup();
renderWithContexts(
<UserMenu user={makeSession({ role: "admin" }).user} />,
);
await user.click(screen.getByRole("button", { name: "User menu" }));
expect(screen.getByRole("menuitem", { name: "Manage" })).toHaveAttribute(
"href",
"/manage",
);
});
test("logs out", async () => {
const session = makeSession({ username: "ann" });
renderApp(<UserMenu user={session.user} />, { session });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "User menu" }));
await user.click(screen.getByRole("menuitem", { name: "Log out" }));
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});

View File

@@ -75,8 +75,6 @@ describe("UserPublicProfile", () => {
expect(dumpsAll).toHaveAttribute("href", "/@ann/dumps");
expect(votesAll).toHaveAttribute("href", "/@ann/upvoted");
await waitFor(() => expect(document.title).toMatch(/^ann · /));
expect(screen.queryByRole("link", { name: "Settings" })).not
.toBeInTheDocument();
// The lists are read by the user's id, which a rename doesn't change.
expect(calls("GET /api/users/:u/dumps")[0].path).toBe(
"/api/users/ann/dumps",
@@ -275,11 +273,11 @@ describe("UserPublicProfile", () => {
expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument();
});
test("visitors can follow; a settings link is only on your own profile", async () => {
test("visitors can follow; inviting is only on your own profile", async () => {
open({ session: makeSession({ id: "bob" }) });
expect(await screen.findByRole("button", { name: "Follow ann" }))
.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Settings" })).not
expect(screen.queryByRole("button", { name: "+ Invite someone" })).not
.toBeInTheDocument();
});
@@ -414,29 +412,18 @@ describe("UserPublicProfile", () => {
}
});
test("your settings, and the site's tools for an admin, are a link away", async () => {
test("an admin's own profile has no manage tab: that's for others'", async () => {
open({
session: { ...annSession, user: { ...annSession.user, role: "admin" } },
user: { ...ann, role: "admin" },
});
expect(await screen.findByRole("link", { name: "Settings" }))
.toHaveAttribute("href", "/settings");
expect(screen.getByRole("link", { name: "Manage" })).toHaveAttribute(
"href",
"/manage",
);
// Their own profile has no manage tab: that's for others'.
expect(await screen.findByRole("button", { name: "+ Invite someone" }))
.toBeInTheDocument();
expect(
within(document.querySelector(".profile-tabs") as HTMLElement)
.queryByRole("button", { name: "Manage" }),
).not.toBeInTheDocument();
});
test("log out", async () => {
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: "Log out" }));
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
});
describe("manage", () => {

View File

@@ -106,6 +106,7 @@
[data-style="brutalist"] .audio-player-vol-btn,
[data-style="brutalist"] .rich-content-thumbnail-btn,
[data-style="brutalist"] .user-menu-trigger,
[data-style="brutalist"] .user-menu-item,
[data-style="brutalist"] .auth-link-btn,
[data-style="brutalist"] .form-cancel,
[data-style="brutalist"] .form-label-action {
@@ -115,6 +116,8 @@
[data-style="brutalist"] .user-menu-trigger:hover:not(:disabled),
[data-style="brutalist"] .user-menu-trigger:active,
[data-style="brutalist"] .user-menu-item:hover:not(:disabled),
[data-style="brutalist"] .user-menu-item:active,
[data-style="brutalist"] .auth-link-btn:hover:not(:disabled),
[data-style="brutalist"] .auth-link-btn:active,
[data-style="brutalist"] .form-cancel:hover:not(:disabled),

View File

@@ -217,6 +217,7 @@
[data-style="geocities"] .audio-player-vol-btn,
[data-style="geocities"] .rich-content-thumbnail-btn,
[data-style="geocities"] .user-menu-trigger,
[data-style="geocities"] .user-menu-item,
[data-style="geocities"] .auth-link-btn,
[data-style="geocities"] .form-cancel,
[data-style="geocities"] .form-label-action,