v4: text is edited where it stands through one component, InlineEdit, and a dump's note and a playlist's title and description join its title, a profile's description and email — those three were written apart, each with its own state, keys, buttons, error and CSS, and the note and the playlist could only be changed in their full editors. InlineEdit (components/form) shows the text; a click on it, or the ✎ after it, opens a field holding it, with Save and Cancel under it. A one-line field takes the text's own type in every style, from the class it is handed (dump-title, playlist-detail-title), and sits on the faint plate the text showed under the pointer, so that nothing moves but the buttons that come; a Markdown text opens the editor comments use. Enter saves a line, Ctrl+Enter a Markdown text, Escape backs out, a text left as it was closes without a request, a required one can't be emptied, a refused save says why in one line beside the buttons, and the focus goes back to the ✎. A click on a link in the text, or one that ends a selection, follows the link or keeps the selection. The ✎ is now a button with the action's name, where a div pretended to be one, and the text keeps its own role, a heading or Markdown. The plate is drawn under the text by a pseudo-element, tinted from its colour: the surface colour it had was the card's own, and showed nothing on the dump page. On a playlist's page the title edits in place beside Follow and Edit, which go to the row's end, and the description too; an empty note or description says what goes there to whoever may write it. DescriptionEditor and EmailEditor go.

The GeoCities marquee stops only for a link the keyboard focused: a link pressed with the pointer took the focus as well, which put the line back at its start and scrolled it, so the link slipped from under the pointer before the button came up and the click was lost. The next page's marquee also carries on from the very point the last one had reached, with the loop it showed: it worked its place out from when the loop began, which ran on while the crawl was paused under the pointer, and showed the feed as it was by then, of another width.

The chat's message field rests on one line, as tall as the Send button: it rested on two, 54 to 62 pixels against the button's 38, and the button sat low beside it. Both take --chat-composer-height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-20 18:08:53 +00:00
parent b92c674726
commit 3e03626cc1
22 changed files with 985 additions and 706 deletions

File diff suppressed because one or more lines are too long

View File

@@ -206,14 +206,14 @@ msgstr "# Welcome to {site}!\n"
"Your account has been created successfully. Welcome aboard!" "Your account has been created successfully. Welcome aboard!"
#: src/components/dump/create/DumpCreateModal.tsx:736 #: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98 #: src/pages/PlaylistDetail.tsx:99
#: src/pages/UserPublicProfile.tsx:129 #: src/pages/UserPublicProfile.tsx:129
msgid "← Back" msgid "← Back"
msgstr "← Back" msgstr "← Back"
#: src/pages/Dump.tsx:375 #: src/pages/Dump.tsx:374
#: src/pages/Dump.tsx:400 #: src/pages/Dump.tsx:399
#: src/pages/Dump.tsx:627 #: src/pages/Dump.tsx:560
#: src/pages/DumpEdit.tsx:234 #: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Back to all dumps" msgstr "← Back to all dumps"
@@ -226,7 +226,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ New playlist" msgstr "+ New playlist"
#: src/pages/Dump.tsx:474 #: src/pages/Dump.tsx:451
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Playlist" msgstr "+ Playlist"
@@ -252,7 +252,7 @@ msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>" msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
#. placeholder {0}: event.username #. placeholder {0}: event.username
#: src/components/social/CommunityFeed.tsx:144 #: src/components/social/CommunityFeed.tsx:145
msgid "<0>{0}</0> joined" msgid "<0>{0}</0> joined"
msgstr "<0>{0}</0> joined" msgstr "<0>{0}</0> joined"
@@ -311,16 +311,16 @@ msgstr "<0>{0}</0> was added to <1>{1}</1>"
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar." msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
msgstr "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar." msgstr "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
#: src/components/social/CommunityFeed.tsx:155 #: src/components/social/CommunityFeed.tsx:159
msgid "<0>Online now:</0> {names}" msgid "<0>Online now:</0> {names}"
msgstr "<0>Online now:</0> {names}" msgstr "<0>Online now:</0> {names}"
#: src/components/social/CommunityFeed.tsx:162 #: src/components/social/CommunityFeed.tsx:166
msgid "<0>This week:</0> <1>{figures}</1>" msgid "<0>This week:</0> <1>{figures}</1>"
msgstr "<0>This week:</0> <1>{figures}</1>" msgstr "<0>This week:</0> <1>{figures}</1>"
#. placeholder {0}: top.title #. placeholder {0}: top.title
#: src/components/social/CommunityFeed.tsx:170 #: src/components/social/CommunityFeed.tsx:174
msgid "<0>Top this week:</0> <1>{0}</1>" msgid "<0>Top this week:</0> <1>{0}</1>"
msgstr "<0>Top this week:</0> <1>{0}</1>" msgstr "<0>Top this week:</0> <1>{0}</1>"
@@ -344,7 +344,7 @@ msgstr "a post"
msgid "Account" msgid "Account"
msgstr "Account" msgstr "Account"
#: src/components/profile/ProfileHeader.tsx:144 #: src/components/profile/ProfileHeader.tsx:151
msgid "Account on <0>{host}</0>" msgid "Account on <0>{host}</0>"
msgstr "Account on <0>{host}</0>" msgstr "Account on <0>{host}</0>"
@@ -352,12 +352,17 @@ msgstr "Account on <0>{host}</0>"
msgid "Add a comment…" msgid "Add a comment…"
msgstr "Add a comment…" msgstr "Add a comment…"
#: src/components/playlist/PlaylistHeader.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:128
msgid "Add a description…"
msgstr "Add a description…"
#: src/components/category/CategoryManager.tsx:115 #: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116 #: src/components/category/CategoryManager.tsx:116
msgid "Add category" msgid "Add category"
msgstr "Add category" msgstr "Add category"
#: src/components/profile/ProfileHeader.tsx:173 #: src/components/profile/ProfileHeader.tsx:168
msgid "Add email…" msgid "Add email…"
msgstr "Add email…" msgstr "Add email…"
@@ -470,12 +475,10 @@ msgstr "Can't sign in there"
#: src/components/comment/CommentForm.tsx:127 #: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:746 #: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42 #: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:277
#: src/components/playlist/PlaylistEditForm.tsx:222 #: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158 #: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561 #: src/pages/DumpEdit.tsx:561
msgid "Cancel" msgid "Cancel"
msgstr "Cancel" msgstr "Cancel"
@@ -501,7 +504,7 @@ msgstr "Categories"
msgid "Category" msgid "Category"
msgstr "Category" msgstr "Category"
#: src/components/profile/ProfileHeader.tsx:102 #: src/components/profile/ProfileHeader.tsx:109
msgid "Change avatar" msgid "Change avatar"
msgstr "Change avatar" msgstr "Change avatar"
@@ -721,7 +724,7 @@ msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:172 #: src/components/playlist/PlaylistCard.tsx:172
#: src/components/playlist/PlaylistEditForm.tsx:213 #: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:224 #: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172 #: src/pages/PlaylistDetail.tsx:174
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Delete playlist" msgstr "Delete playlist"
@@ -738,7 +741,7 @@ msgid "Delete this message?"
msgstr "Delete this message?" msgstr "Delete this message?"
#: src/components/profile/ProfilePlaylistsTab.tsx:223 #: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171 #: src/pages/PlaylistDetail.tsx:173
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone."
@@ -827,8 +830,8 @@ msgstr "Earlier"
#: src/components/chat/ChatMessageItem.tsx:108 #: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256 #: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:64 #: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:623 #: src/pages/Dump.tsx:556
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@@ -837,16 +840,22 @@ msgstr "Edit"
msgid "Edit {0}" msgid "Edit {0}"
msgstr "Edit {0}" msgstr "Edit {0}"
#: src/components/profile/ProfileDescription.tsx:58 #: src/components/playlist/PlaylistHeader.tsx:113
#: src/components/profile/ProfileDescription.tsx:45
msgid "Edit description" msgid "Edit description"
msgstr "Edit description" msgstr "Edit description"
#: src/components/profile/ProfileHeader.tsx:171 #: src/components/profile/ProfileHeader.tsx:161
msgid "Edit email" msgid "Edit email"
msgstr "Edit email" msgstr "Edit email"
#: src/pages/Dump.tsx:510
msgid "Edit note"
msgstr "Edit note"
#: src/components/dump/create/TitleField.tsx:69 #: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:533 #: src/components/playlist/PlaylistHeader.tsx:84
#: src/pages/Dump.tsx:459
msgid "Edit title" msgid "Edit title"
msgstr "Edit title" msgstr "Edit title"
@@ -859,8 +868,8 @@ msgstr "edited"
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132 #: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205 #: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:99 #: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:576 #: src/pages/Dump.tsx:492
msgid "edited {0}" msgid "edited {0}"
msgstr "edited {0}" msgstr "edited {0}"
@@ -869,8 +878,8 @@ msgstr "edited {0}"
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:97 #: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:574 #: src/pages/Dump.tsx:490
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Edited {0}" msgstr "Edited {0}"
@@ -936,8 +945,6 @@ msgstr "Failed to post reply"
#: src/components/federation/GrantList.tsx:98 #: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158 #: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:207 #: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save" msgid "Failed to save"
msgstr "Failed to save" msgstr "Failed to save"
@@ -949,7 +956,7 @@ msgstr "Failed to save category"
msgid "Failed to save edit" msgid "Failed to save edit"
msgstr "Failed to save edit" msgstr "Failed to save edit"
#: src/components/profile/ProfileHeader.tsx:182 #: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Failed to update avatar" msgstr "Failed to update avatar"
@@ -1064,7 +1071,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
msgid "image" msgid "image"
msgstr "image" msgstr "image"
#: src/pages/Dump.tsx:643 #: src/pages/Dump.tsx:576
msgid "In collections" msgid "In collections"
msgstr "In collections" msgstr "In collections"
@@ -1095,7 +1102,7 @@ msgstr "Invalid link"
msgid "Invite link" msgid "Invite link"
msgstr "Invite link" msgstr "Invite link"
#: src/components/profile/ProfileHeader.tsx:126 #: src/components/profile/ProfileHeader.tsx:133
msgid "invited by" msgid "invited by"
msgstr "invited by" msgstr "invited by"
@@ -1165,7 +1172,7 @@ msgstr "Load more"
msgid "Load older messages" msgid "Load older messages"
msgstr "Load older messages" msgstr "Load older messages"
#: src/pages/Dump.tsx:359 #: src/pages/Dump.tsx:358
#: src/pages/DumpEdit.tsx:210 #: src/pages/DumpEdit.tsx:210
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Loading dump…" msgstr "Loading dump…"
@@ -1178,7 +1185,7 @@ msgstr "Loading messages…"
msgid "Loading more…" msgid "Loading more…"
msgstr "Loading more…" msgstr "Loading more…"
#: src/pages/PlaylistDetail.tsx:82 #: src/pages/PlaylistDetail.tsx:83
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Loading playlist…" msgstr "Loading playlist…"
@@ -1548,9 +1555,9 @@ msgstr "Previous track"
#: src/components/dump/DumpCard.tsx:144 #: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:146 #: src/components/dump/JournalCard.tsx:146
#: src/components/playlist/PlaylistCard.tsx:127 #: src/components/playlist/PlaylistCard.tsx:127
#: src/components/playlist/PlaylistHeader.tsx:81 #: src/components/playlist/PlaylistHeader.tsx:140
#: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:582 #: src/pages/Dump.tsx:498
msgid "private" msgid "private"
msgstr "private" msgstr "private"
@@ -1559,7 +1566,7 @@ msgid "Private"
msgstr "Private" msgstr "Private"
#: src/components/playlist/PlaylistCard.tsx:126 #: src/components/playlist/PlaylistCard.tsx:126
#: src/components/playlist/PlaylistHeader.tsx:81 #: src/components/playlist/PlaylistHeader.tsx:140
msgid "public" msgid "public"
msgstr "public" msgstr "public"
@@ -1593,7 +1600,7 @@ msgstr "Registering…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Registration failed" msgstr "Registration failed"
#: src/pages/Dump.tsx:634 #: src/pages/Dump.tsx:567
msgid "Related" msgid "Related"
msgstr "Related" msgstr "Related"
@@ -1680,7 +1687,7 @@ msgid "email.reset.title"
msgstr "Reset your password" msgstr "Reset your password"
#: src/components/chat/PendingItem.tsx:86 #: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:393 #: src/pages/Dump.tsx:392
#: src/pages/DumpEdit.tsx:227 #: src/pages/DumpEdit.tsx:227
msgid "Retry" msgid "Retry"
msgstr "Retry" msgstr "Retry"
@@ -1692,20 +1699,16 @@ msgstr "Role"
#: src/components/category/CategoryManager.tsx:232 #: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216 #: src/components/comment/CommentNode.tsx:216
#: src/components/form/InlineEdit.tsx:269
#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564 #: src/pages/DumpEdit.tsx:564
msgid "Save" msgid "Save"
msgstr "Save" msgstr "Save"
#: src/components/comment/CommentNode.tsx:217 #: src/components/comment/CommentNode.tsx:217
#: src/components/form/InlineEdit.tsx:266
#: src/components/playlist/PlaylistEditForm.tsx:226 #: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191 #: src/pages/ResetPassword.tsx:191
msgid "Saving…" msgid "Saving…"
msgstr "Saving…" msgstr "Saving…"
@@ -1815,7 +1818,7 @@ msgstr "Someone asked to reset the password of your {site} account. The button b
#: src/components/form/FormError.tsx:31 #: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29 #: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:738 #: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155 #: src/pages/PlaylistDetail.tsx:157
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Something went wrong" msgstr "Something went wrong"
@@ -1879,7 +1882,7 @@ msgstr "The new order couldn't be saved. Try again."
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again."
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:367
msgid "This dump was deleted, or made private." msgid "This dump was deleted, or made private."
msgstr "This dump was deleted, or made private." msgstr "This dump was deleted, or made private."
@@ -2033,7 +2036,7 @@ msgstr "updated {ago}"
msgid "email.digest.upload" msgid "email.digest.upload"
msgstr "upload" msgstr "upload"
#: src/components/profile/ProfileHeader.tsx:79 #: src/components/profile/ProfileHeader.tsx:80
msgid "Upload failed" msgid "Upload failed"
msgstr "Upload failed" msgstr "Upload failed"
@@ -2136,12 +2139,14 @@ msgid "email.welcome.subject"
msgstr "Welcome to {site}" msgstr "Welcome to {site}"
#: src/components/dump/create/DumpCreateModal.tsx:705 #: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/Dump.tsx:514
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:539 #: src/pages/DumpEdit.tsx:539
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "What makes it worth it?" msgstr "What makes it worth it?"
#: src/components/profile/DescriptionEditor.tsx:78 #: src/components/profile/ProfileDescription.tsx:62
#: src/components/profile/ProfileDescription.tsx:79 #: src/components/profile/ProfileDescription.tsx:72
msgid "Who am I?" msgid "Who am I?"
msgstr "Who am I?" msgstr "Who am I?"

File diff suppressed because one or more lines are too long

View File

@@ -206,14 +206,14 @@ msgstr "# Bienvenue sur {site} !\n"
"Votre compte a bien été créé. Bienvenue à bord !" "Votre compte a bien été créé. Bienvenue à bord !"
#: src/components/dump/create/DumpCreateModal.tsx:736 #: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98 #: src/pages/PlaylistDetail.tsx:99
#: src/pages/UserPublicProfile.tsx:129 #: src/pages/UserPublicProfile.tsx:129
msgid "← Back" msgid "← Back"
msgstr "← Retour" msgstr "← Retour"
#: src/pages/Dump.tsx:375 #: src/pages/Dump.tsx:374
#: src/pages/Dump.tsx:400 #: src/pages/Dump.tsx:399
#: src/pages/Dump.tsx:627 #: src/pages/Dump.tsx:560
#: src/pages/DumpEdit.tsx:234 #: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos" msgstr "← Retour à toutes les recos"
@@ -226,7 +226,7 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ Nouvelle collection" msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:474 #: src/pages/Dump.tsx:451
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Collection" msgstr "+ Collection"
@@ -252,7 +252,7 @@ msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>" msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
#. placeholder {0}: event.username #. placeholder {0}: event.username
#: src/components/social/CommunityFeed.tsx:144 #: src/components/social/CommunityFeed.tsx:145
msgid "<0>{0}</0> joined" msgid "<0>{0}</0> joined"
msgstr "<0>{0}</0> a rejoint le site" msgstr "<0>{0}</0> a rejoint le site"
@@ -311,16 +311,16 @@ msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar." msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
msgstr "<0>{name}</0> ({host}) demande à vous connecter avec votre compte d'ici, <1>@{username}</1>. Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar." msgstr "<0>{name}</0> ({host}) demande à vous connecter avec votre compte d'ici, <1>@{username}</1>. Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar."
#: src/components/social/CommunityFeed.tsx:155 #: src/components/social/CommunityFeed.tsx:159
msgid "<0>Online now:</0> {names}" msgid "<0>Online now:</0> {names}"
msgstr "<0>En ligne :</0> {names}" msgstr "<0>En ligne :</0> {names}"
#: src/components/social/CommunityFeed.tsx:162 #: src/components/social/CommunityFeed.tsx:166
msgid "<0>This week:</0> <1>{figures}</1>" msgid "<0>This week:</0> <1>{figures}</1>"
msgstr "<0>Cette semaine :</0> <1>{figures}</1>" msgstr "<0>Cette semaine :</0> <1>{figures}</1>"
#. placeholder {0}: top.title #. placeholder {0}: top.title
#: src/components/social/CommunityFeed.tsx:170 #: src/components/social/CommunityFeed.tsx:174
msgid "<0>Top this week:</0> <1>{0}</1>" msgid "<0>Top this week:</0> <1>{0}</1>"
msgstr "<0>En tête cette semaine :</0> <1>{0}</1>" msgstr "<0>En tête cette semaine :</0> <1>{0}</1>"
@@ -344,7 +344,7 @@ msgstr "une publication"
msgid "Account" msgid "Account"
msgstr "Compte" msgstr "Compte"
#: src/components/profile/ProfileHeader.tsx:144 #: src/components/profile/ProfileHeader.tsx:151
msgid "Account on <0>{host}</0>" msgid "Account on <0>{host}</0>"
msgstr "Compte sur <0>{host}</0>" msgstr "Compte sur <0>{host}</0>"
@@ -352,12 +352,17 @@ msgstr "Compte sur <0>{host}</0>"
msgid "Add a comment…" msgid "Add a comment…"
msgstr "Ajouter un commentaire…" msgstr "Ajouter un commentaire…"
#: src/components/playlist/PlaylistHeader.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:128
msgid "Add a description…"
msgstr "Ajouter une description…"
#: src/components/category/CategoryManager.tsx:115 #: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116 #: src/components/category/CategoryManager.tsx:116
msgid "Add category" msgid "Add category"
msgstr "Ajouter une catégorie" msgstr "Ajouter une catégorie"
#: src/components/profile/ProfileHeader.tsx:173 #: src/components/profile/ProfileHeader.tsx:168
msgid "Add email…" msgid "Add email…"
msgstr "Ajouter un e-mail…" msgstr "Ajouter un e-mail…"
@@ -470,12 +475,10 @@ msgstr "Connexion impossible"
#: src/components/comment/CommentForm.tsx:127 #: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:746 #: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42 #: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:277
#: src/components/playlist/PlaylistEditForm.tsx:222 #: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158 #: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561 #: src/pages/DumpEdit.tsx:561
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@@ -501,7 +504,7 @@ msgstr "Catégories"
msgid "Category" msgid "Category"
msgstr "Catégorie" msgstr "Catégorie"
#: src/components/profile/ProfileHeader.tsx:102 #: src/components/profile/ProfileHeader.tsx:109
msgid "Change avatar" msgid "Change avatar"
msgstr "Changer l'avatar" msgstr "Changer l'avatar"
@@ -721,7 +724,7 @@ msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:172 #: src/components/playlist/PlaylistCard.tsx:172
#: src/components/playlist/PlaylistEditForm.tsx:213 #: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:224 #: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172 #: src/pages/PlaylistDetail.tsx:174
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Supprimer la collection" msgstr "Supprimer la collection"
@@ -738,7 +741,7 @@ msgid "Delete this message?"
msgstr "Supprimer ce message ?" msgstr "Supprimer ce message ?"
#: src/components/profile/ProfilePlaylistsTab.tsx:223 #: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171 #: src/pages/PlaylistDetail.tsx:173
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible." msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -827,8 +830,8 @@ msgstr "Plus tôt"
#: src/components/chat/ChatMessageItem.tsx:108 #: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256 #: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:64 #: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:623 #: src/pages/Dump.tsx:556
msgid "Edit" msgid "Edit"
msgstr "Modifier" msgstr "Modifier"
@@ -837,16 +840,22 @@ msgstr "Modifier"
msgid "Edit {0}" msgid "Edit {0}"
msgstr "Modifier {0}" msgstr "Modifier {0}"
#: src/components/profile/ProfileDescription.tsx:58 #: src/components/playlist/PlaylistHeader.tsx:113
#: src/components/profile/ProfileDescription.tsx:45
msgid "Edit description" msgid "Edit description"
msgstr "Modifier la description" msgstr "Modifier la description"
#: src/components/profile/ProfileHeader.tsx:171 #: src/components/profile/ProfileHeader.tsx:161
msgid "Edit email" msgid "Edit email"
msgstr "Modifier l'e-mail" msgstr "Modifier l'e-mail"
#: src/pages/Dump.tsx:510
msgid "Edit note"
msgstr "Modifier la note"
#: src/components/dump/create/TitleField.tsx:69 #: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:533 #: src/components/playlist/PlaylistHeader.tsx:84
#: src/pages/Dump.tsx:459
msgid "Edit title" msgid "Edit title"
msgstr "Modifier le titre" msgstr "Modifier le titre"
@@ -859,8 +868,8 @@ msgstr "modifié"
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132 #: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205 #: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:99 #: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:576 #: src/pages/Dump.tsx:492
msgid "edited {0}" msgid "edited {0}"
msgstr "modifié {0}" msgstr "modifié {0}"
@@ -869,8 +878,8 @@ msgstr "modifié {0}"
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:97 #: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:574 #: src/pages/Dump.tsx:490
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Modifié le {0}" msgstr "Modifié le {0}"
@@ -936,8 +945,6 @@ msgstr "Impossible de publier la réponse"
#: src/components/federation/GrantList.tsx:98 #: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158 #: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:207 #: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save" msgid "Failed to save"
msgstr "Enregistrement échoué" msgstr "Enregistrement échoué"
@@ -949,7 +956,7 @@ msgstr "Échec de l’enregistrement de la catégorie"
msgid "Failed to save edit" msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification" msgstr "Impossible d'enregistrer la modification"
#: src/components/profile/ProfileHeader.tsx:182 #: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar" msgstr "Impossible de mettre à jour l'avatar"
@@ -1064,7 +1071,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
msgid "image" msgid "image"
msgstr "image" msgstr "image"
#: src/pages/Dump.tsx:643 #: src/pages/Dump.tsx:576
msgid "In collections" msgid "In collections"
msgstr "Dans les collections" msgstr "Dans les collections"
@@ -1095,7 +1102,7 @@ msgstr "Lien invalide"
msgid "Invite link" msgid "Invite link"
msgstr "Lien d'invitation" msgstr "Lien d'invitation"
#: src/components/profile/ProfileHeader.tsx:126 #: src/components/profile/ProfileHeader.tsx:133
msgid "invited by" msgid "invited by"
msgstr "invité par" msgstr "invité par"
@@ -1165,7 +1172,7 @@ msgstr "Charger plus"
msgid "Load older messages" msgid "Load older messages"
msgstr "Charger les messages plus anciens" msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:359 #: src/pages/Dump.tsx:358
#: src/pages/DumpEdit.tsx:210 #: src/pages/DumpEdit.tsx:210
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Chargement de la reco…" msgstr "Chargement de la reco…"
@@ -1178,7 +1185,7 @@ msgstr "Chargement des messages…"
msgid "Loading more…" msgid "Loading more…"
msgstr "Chargement…" msgstr "Chargement…"
#: src/pages/PlaylistDetail.tsx:82 #: src/pages/PlaylistDetail.tsx:83
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Chargement de la collection…" msgstr "Chargement de la collection…"
@@ -1548,9 +1555,9 @@ msgstr "Piste précédente"
#: src/components/dump/DumpCard.tsx:144 #: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:146 #: src/components/dump/JournalCard.tsx:146
#: src/components/playlist/PlaylistCard.tsx:127 #: src/components/playlist/PlaylistCard.tsx:127
#: src/components/playlist/PlaylistHeader.tsx:81 #: src/components/playlist/PlaylistHeader.tsx:140
#: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:582 #: src/pages/Dump.tsx:498
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
@@ -1559,7 +1566,7 @@ msgid "Private"
msgstr "Privé" msgstr "Privé"
#: src/components/playlist/PlaylistCard.tsx:126 #: src/components/playlist/PlaylistCard.tsx:126
#: src/components/playlist/PlaylistHeader.tsx:81 #: src/components/playlist/PlaylistHeader.tsx:140
msgid "public" msgid "public"
msgstr "public" msgstr "public"
@@ -1593,7 +1600,7 @@ msgstr "Inscription…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Inscription échouée" msgstr "Inscription échouée"
#: src/pages/Dump.tsx:634 #: src/pages/Dump.tsx:567
msgid "Related" msgid "Related"
msgstr "Connexe" msgstr "Connexe"
@@ -1680,7 +1687,7 @@ msgid "email.reset.title"
msgstr "Réinitialiser votre mot de passe" msgstr "Réinitialiser votre mot de passe"
#: src/components/chat/PendingItem.tsx:86 #: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:393 #: src/pages/Dump.tsx:392
#: src/pages/DumpEdit.tsx:227 #: src/pages/DumpEdit.tsx:227
msgid "Retry" msgid "Retry"
msgstr "Réessayer" msgstr "Réessayer"
@@ -1692,20 +1699,16 @@ msgstr "Rôle"
#: src/components/category/CategoryManager.tsx:232 #: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216 #: src/components/comment/CommentNode.tsx:216
#: src/components/form/InlineEdit.tsx:269
#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564 #: src/pages/DumpEdit.tsx:564
msgid "Save" msgid "Save"
msgstr "Enregistrer" msgstr "Enregistrer"
#: src/components/comment/CommentNode.tsx:217 #: src/components/comment/CommentNode.tsx:217
#: src/components/form/InlineEdit.tsx:266
#: src/components/playlist/PlaylistEditForm.tsx:226 #: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191 #: src/pages/ResetPassword.tsx:191
msgid "Saving…" msgid "Saving…"
msgstr "Enregistrement…" msgstr "Enregistrement…"
@@ -1815,7 +1818,7 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {
#: src/components/form/FormError.tsx:31 #: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29 #: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:738 #: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155 #: src/pages/PlaylistDetail.tsx:157
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Une erreur est survenue" msgstr "Une erreur est survenue"
@@ -1879,7 +1882,7 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:367
msgid "This dump was deleted, or made private." msgid "This dump was deleted, or made private."
msgstr "Cette reco a été supprimée, ou rendue privée." msgstr "Cette reco a été supprimée, ou rendue privée."
@@ -2033,7 +2036,7 @@ msgstr "mise à jour {ago}"
msgid "email.digest.upload" msgid "email.digest.upload"
msgstr "fichier" msgstr "fichier"
#: src/components/profile/ProfileHeader.tsx:79 #: src/components/profile/ProfileHeader.tsx:80
msgid "Upload failed" msgid "Upload failed"
msgstr "Envoi échoué" msgstr "Envoi échoué"
@@ -2136,12 +2139,14 @@ msgid "email.welcome.subject"
msgstr "Bienvenue sur {site}" msgstr "Bienvenue sur {site}"
#: src/components/dump/create/DumpCreateModal.tsx:705 #: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/Dump.tsx:514
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:539 #: src/pages/DumpEdit.tsx:539
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?" msgstr "Pourquoi on en voudrait ?"
#: src/components/profile/DescriptionEditor.tsx:78 #: src/components/profile/ProfileDescription.tsx:62
#: src/components/profile/ProfileDescription.tsx:79 #: src/components/profile/ProfileDescription.tsx:72
msgid "Who am I?" msgid "Who am I?"
msgstr "Qui suis-je ?" msgstr "Qui suis-je ?"

View File

@@ -223,67 +223,151 @@
word-break: break-word; word-break: break-word;
} }
.dump-title-view { /* ── Inline edit ── */
/* Text edited where it stands (InlineEdit.tsx). Hovered, the text sits on a
faint plate with a ✎ after it; opened, the field takes the plate's place
and the text's type, so that nothing jumps but the buttons that come under
it. The plate spills past the text, drawn under it by a pseudo-element: the
text's own box keeps its size, which a row measuring it (a playlist's title
beside its buttons) depends on. */
.inline-edit {
/* Tinted from the text, as it sits on a card or on the page alike. */
--inline-edit-plate: color-mix(in srgb, var(--color-text) 7%, transparent);
--inline-edit-pad-y: 0.1rem;
--inline-edit-pad-x: 0.45rem;
min-width: 0;
max-width: 100%;
}
.inline-edit--block {
--inline-edit-pad-y: 0.45rem;
--inline-edit-pad-x: 0.6rem;
}
.inline-edit-view {
position: relative; position: relative;
isolation: isolate;
max-width: 100%;
cursor: text;
}
.inline-edit-view::before {
content: "";
position: absolute;
inset: calc(-1 * var(--inline-edit-pad-y))
calc(-1 * var(--inline-edit-pad-x));
z-index: -1;
border-radius: var(--radius-md);
transition: background-color var(--dur-fast);
}
.inline-edit--line .inline-edit-view {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4em;
border-radius: var(--radius-md);
max-width: 100%;
/* Padding gives the hover box breathing room (incl. right of the pencil);
the matching negative margin keeps the title's visual position unchanged. */
padding: 0.1rem 0.45rem;
margin: -0.1rem -0.45rem;
} }
.dump-title-view--editable { /* Room on the right for the ✎, which sits in the corner. */
cursor: pointer; .inline-edit--block .inline-edit-view {
display: block;
padding-right: 1.5rem;
} }
.dump-title-view--editable:hover { .inline-edit-view:hover::before {
background: var(--color-surface); background: var(--inline-edit-plate);
} }
.dump-title-edit-btn { .inline-edit-mark {
font-size: var(--text-sm);
line-height: 1;
flex-shrink: 0; flex-shrink: 0;
padding: 0.15em;
border: none;
border-radius: var(--radius-sm);
background: none;
font-family: inherit;
font-size: var(--text-sm);
font-weight: 400;
line-height: 1;
color: var(--color-text-muted); color: var(--color-text-muted);
cursor: pointer;
opacity: 0; opacity: 0;
transition: opacity var(--dur-fast); transition: opacity var(--dur-fast);
pointer-events: none;
} }
.dump-title-view--editable:hover .dump-title-edit-btn, .inline-edit--block .inline-edit-mark {
.dump-title-view--editable:focus-visible .dump-title-edit-btn { position: absolute;
top: 0;
right: 0;
}
.inline-edit-view:hover .inline-edit-mark,
.inline-edit-mark:focus-visible {
opacity: 1; opacity: 1;
} }
.dump-title-editor { /* No pointer to hover with: the mark stays, faintly, to say it can be
tapped. */
@media (hover: none) {
.inline-edit-mark {
opacity: 0.45;
}
}
/* What an empty text says to the one who may fill it. */
.inline-edit-empty {
margin: 0;
color: var(--color-text-muted);
font-style: italic;
}
.inline-edit-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
/* Typed as the text it replaces: the type is inherited at no specificity, so
that the class of that text (`fieldClassName`) and a style's rules for it
both set it over this. */
:where(.inline-edit-input) {
font: inherit;
letter-spacing: inherit;
color: inherit;
}
.inline-edit-input {
box-sizing: border-box;
width: calc(100% + 2 * var(--inline-edit-pad-x));
min-width: 0;
padding: calc(var(--inline-edit-pad-y) - var(--border-width))
calc(var(--inline-edit-pad-x) - var(--border-width));
margin: calc(-1 * var(--inline-edit-pad-y))
calc(-1 * var(--inline-edit-pad-x));
border: var(--border-width) solid transparent;
border-radius: var(--radius-md);
background: var(--inline-edit-plate);
transition: border-color var(--dur-base), box-shadow var(--dur-base);
}
/* As the Markdown editor's box takes the focus. */
.inline-edit-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.inline-edit-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.4rem;
} }
/* Shared chrome for inline-edit inputs (the editors that live outside `.form`). .inline-edit-error {
Each rule below adds only its own size / weight / width deltas. */ margin: 0;
.dump-title-input, font-size: var(--text-sm);
.profile-email-input, color: var(--color-danger);
.username-field-input {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
outline: none;
transition: border-color var(--dur-slow);
}
.dump-title-input:focus,
.profile-email-input:focus,
.username-field-input:focus {
border-color: var(--color-accent);
} }
/* A name and its "Rename" button, the hint and errors below. */ /* A name and its "Rename" button, the hint and errors below. */
@@ -304,28 +388,18 @@
flex: 1 1 10rem; flex: 1 1 10rem;
min-width: 0; min-width: 0;
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
font-size: var(--text-base); font-size: var(--text-base);
outline: none;
transition: border-color var(--dur-slow);
} }
.dump-title-input { .username-field-input:focus {
flex: 1; border-color: var(--color-accent);
min-width: 12rem;
padding: 0.4rem 0.6rem;
font-size: var(--text-xl);
font-weight: 700;
}
/* Save and Cancel wrap together, under the input on a phone. */
.dump-title-actions {
display: flex;
gap: 0.5rem;
}
.dump-title-error {
width: 100%;
margin: 0;
font-size: var(--text-sm);
color: var(--color-danger);
} }
/* On the narrowest viewports the fixed-width action column squeezes the title /* On the narrowest viewports the fixed-width action column squeezes the title
@@ -2357,23 +2431,9 @@ body.has-player .chat-fab {
/* No margins of its own: the page's gap already sets it apart. */ /* No margins of its own: the page's gap already sets it apart. */
.profile-description { .profile-description {
width: 100%; width: 100%;
}
.profile-description-view {
position: relative;
padding: 0.5rem 0.6rem;
border-radius: var(--radius-md);
font-size: var(--text-md); font-size: var(--text-md);
} }
.profile-description-view--editable {
cursor: pointer;
}
.profile-description-view--editable:hover {
background: var(--color-surface);
}
.profile-description-text { .profile-description-text {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -2381,80 +2441,16 @@ body.has-player .chat-fab {
line-height: 1.6; line-height: 1.6;
} }
.profile-description-empty { .profile-email {
color: var(--color-muted); margin: 0.1rem 0 0.4rem;
font-style: italic;
}
.profile-description-edit-btn {
position: absolute;
top: 0.55rem;
right: 0.5rem;
font-size: var(--text-sm);
color: var(--color-muted);
opacity: 0;
transition: opacity var(--dur-fast);
pointer-events: none;
}
.profile-description-view--editable:hover .profile-description-edit-btn,
.profile-description-view--editable:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-description-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profile-email-display {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 0.35rem;
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--color-text-muted); color: var(--color-text-muted);
margin: 0.1rem 0 0.4rem;
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0.1rem 0.2rem;
margin-left: -0.2rem;
} }
.profile-email-display:hover { /* An address is typed a size up from the line it shows on. */
background: var(--color-surface); .profile-email .inline-edit-input {
} max-width: 22rem;
.profile-email-display:hover .profile-description-edit-btn,
.profile-email-display:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-email-editor {
margin: 0.2rem 0 0.4rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.profile-email-input {
width: min(260px, 100%);
padding: 0.35rem 0.6rem;
border-radius: var(--radius-md);
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: 1.5;
}
.profile-email-actions {
display: flex;
gap: 0.4rem;
}
.profile-description-actions {
display: flex;
align-items: center;
gap: 0.5rem;
} }
.profile-invited-by { .profile-invited-by {
@@ -5125,7 +5121,10 @@ body.has-player .chat-fab {
gap: 0.4rem; gap: 0.4rem;
} }
/* The input rests on one line, as tall as the Send button beside it: both
take --chat-composer-height, which any style's line of text fits in. */
.chat-composer { .chat-composer {
--chat-composer-height: 2.75rem;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: flex-end; align-items: flex-end;
@@ -5154,6 +5153,10 @@ body.has-player .chat-fab {
above the input, in the gap over the composer: inside the textarea, the text above the input, in the gap over the composer: inside the textarea, the text
ran under them, and in a row below they would knock the Send button out of ran under them, and in a row below they would knock the Send button out of
alignment. */ alignment. */
.chat-composer .chat-input {
min-height: var(--chat-composer-height);
}
.chat-composer .text-editor-footer { .chat-composer .text-editor-footer {
position: absolute; position: absolute;
left: 0.25rem; left: 0.25rem;
@@ -5168,9 +5171,9 @@ body.has-player .chat-fab {
.chat-send-btn { .chat-send-btn {
flex-shrink: 0; flex-shrink: 0;
/* Match the input's resting height so the two line up exactly (both edges), /* The input's resting height, so that the two line up at both edges, while
while the input is free to grow taller as you type. */ the input is free to grow taller as you type. */
height: 2.4rem; height: var(--chat-composer-height);
box-sizing: border-box; box-sizing: border-box;
} }
@@ -5518,6 +5521,23 @@ body.has-player .chat-fab {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* The open title field takes the room its buttons leave beside it, which
line up with its top rather than with its Save and Cancel. */
.playlist-detail-title-row:has(> .inline-edit--editing) {
align-items: flex-start;
}
.playlist-detail-title-row > .inline-edit--editing {
flex: 1 1 16rem;
/* Clear of the buttons by the gap, past the field's spill. */
margin-right: var(--inline-edit-pad-x);
}
/* The title on the left, Follow and Edit together at the row's end. */
.playlist-detail-title-row > :first-child:not(.inline-edit--editing) {
margin-right: auto;
}
.playlist-detail-title { .playlist-detail-title {
margin: 0; margin: 0;
font-size: var(--text-2xl); font-size: var(--text-2xl);

View File

@@ -850,7 +850,7 @@ export function ChatModal(
value={draft} value={draft}
onChange={handleDraftChange} onChange={handleDraftChange}
placeholder={t`Type a message…`} placeholder={t`Type a message…`}
rows={2} rows={1}
autoResize autoResize
maxLength={VALIDATION.CHAT_MESSAGE_MAX} maxLength={VALIDATION.CHAT_MESSAGE_MAX}
countFrom={COUNT_FROM} countFrom={COUNT_FROM}

View File

@@ -0,0 +1,299 @@
/**
* Renders a piece of text that whoever may change it edits where it stands,
* with no page or form of its own: a dump's title and note on its page, a
* playlist's title and description on its page, a profile's description and
* email on one's own profile.
*
* @module
*/
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider, useFormState } from "react-hook-form";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
/**
* Passed to {@link InlineEdit} by the component showing the text, which
* renders it as `children` and saves it in `onSave`.
*/
export interface InlineEditProps {
/** The text as saved, which the field opens on. */
value: string;
/** Without it, `children` show alone. */
editable: boolean;
/** Names the action, such as "Edit title": the text's tooltip. */
label: string;
/**
* Saves the trimmed text, and shows it: the field closes once it resolves.
* A thrown error, such as `expectOk`'s, stays open under the field. A text
* left as it was closes the field without calling it.
*/
onSave: (value: string) => Promise<void>;
/**
* A one-line field (`text`, `email`), where Enter saves, or the Markdown
* editor (`markdown`), where Ctrl/Cmd+Enter does. Escape cancels in both.
*/
kind?: "text" | "email" | "markdown";
/** Refuses to save an empty text. */
required?: boolean;
maxLength?: number;
placeholder?: string;
/** Added to the wrapper, which the text and the field both sit in. */
className?: string;
/**
* Added to a one-line field: the class of the text it edits, such as
* `dump-title`, so that the field takes on its type in every style.
*/
fieldClassName?: string;
children: ReactNode;
}
/**
* Renders `children`, which a click on them, or the ✎ button after them,
* swaps for a field holding `value`, with Save and Cancel under it. The ✎
* shows on hover and on keyboard focus. A click on a link in the text, or one
* that ends a selection, follows the link or keeps the selection instead.
* Closing the field gives the focus back to the ✎ when the field had it.
*/
export function InlineEdit(
{
value,
editable,
label,
onSave,
kind = "text",
required = false,
maxLength,
placeholder,
className,
fieldClassName,
children,
}: InlineEditProps,
): React.JSX.Element {
const [editing, setEditing] = useState(false);
const heightRef = useHeightTransition<HTMLDivElement>(editing);
const rootRef = useRef<HTMLDivElement | null>(null);
const setRoot = useCallback((el: HTMLDivElement | null) => {
rootRef.current = el;
return heightRef(el);
}, [heightRef]);
const markRef = useRef<HTMLButtonElement>(null);
const refocusRef = useRef(false);
useEffect(() => {
if (editing || !refocusRef.current) return;
refocusRef.current = false;
markRef.current?.focus({ preventScroll: true });
}, [editing]);
if (!editable) return <>{children}</>;
const close = () => {
// A field disabled while it saved has dropped the focus to the body.
refocusRef.current = !!rootRef.current?.contains(document.activeElement) ||
document.activeElement === document.body;
setEditing(false);
};
return (
<div
ref={setRoot}
className={`inline-edit inline-edit--${
kind === "markdown" ? "block" : "line"
}${editing ? " inline-edit--editing" : ""}${
className ? ` ${className}` : ""
}`}
>
{editing
? (
<InlineEditForm
value={value}
onSave={onSave}
onClose={close}
kind={kind}
required={required}
maxLength={maxLength}
placeholder={placeholder}
fieldClassName={fieldClassName}
label={label}
/>
)
: (
<div
className="inline-edit-view"
title={label}
// The text opens the field to the pointer; the keyboard has the ✎.
// Neither is a button around the text, which keeps its own role:
// a heading, Markdown with its links.
onClick={(e) => {
if ((e.target as Element).closest("a")) return;
if (getSelection()?.type === "Range") return;
setEditing(true);
}}
>
{children}
<button
ref={markRef}
type="button"
className="inline-edit-mark"
aria-label={label}
// Opens the field whatever is selected on the page.
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
>
✎
</button>
</div>
)}
</div>
);
}
type InlineEditFormProps =
& Omit<InlineEditProps, "editable" | "className" | "children">
& { onClose: () => void };
/** The field, mounted afresh on each opening so it starts from `value`. */
function InlineEditForm(
{
value,
onSave,
onClose,
kind = "text",
required,
maxLength,
placeholder,
fieldClassName,
label,
}: InlineEditFormProps,
): React.JSX.Element {
const form = useApiForm<{ text: string }>({
defaultValues: { text: value },
});
const text = form.watch("text");
const submitting = form.formState.isSubmitting;
const editorRef = useRef<TextEditorHandle>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// The caret goes after the text, where an edit most often begins.
useEffect(() => {
editorRef.current?.focus({ atEnd: true, preventScroll: true });
const input = inputRef.current;
if (!input) return;
input.focus({ preventScroll: true });
// An email field has no selection to set.
if (input.type === "text") {
input.setSelectionRange(input.value.length, input.value.length);
}
}, []);
const tooLong = maxLength !== undefined && text.length > maxLength;
const blank = required && !text.trim();
const onSubmit = form.submit(async ({ text }) => {
const trimmed = text.trim();
if (trimmed !== value.trim()) await onSave(trimmed);
onClose();
});
const cancel = () => {
if (!submitting) onClose();
};
const { ref: registerRef, ...registration } = form.register("text");
return (
<FormProvider {...form}>
<form className="inline-edit-form animate-in" onSubmit={onSubmit}>
{kind === "markdown"
? (
<Controller
name="text"
control={form.control}
render={({ field }) => (
<TextEditor
ref={editorRef}
className="comment-reply-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => {
if (!tooLong && !blank) void onSubmit();
}}
onCancel={cancel}
placeholder={placeholder}
autoResize
maxLength={maxLength}
disabled={submitting}
/>
)}
/>
)
: (
<input
{...registration}
ref={(el) => {
registerRef(el);
inputRef.current = el;
}}
type={kind}
className={`inline-edit-input${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
aria-label={label}
placeholder={placeholder}
maxLength={maxLength}
disabled={submitting}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
/>
)}
<div className="inline-edit-actions">
<SubmitButton
className="btn-primary btn-sm"
pendingLabel={<Trans>Saving…</Trans>}
disabled={tooLong || blank}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary btn-sm"
onClick={cancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<InlineEditError />
</div>
</form>
</FormProvider>
);
}
/**
* Renders why the save failed, as one line beside the buttons: an
* `ErrorCard` would outweigh the field it is about.
*/
function InlineEditError(): React.JSX.Element | null {
const { errors } = useFormState();
const message = errors.root?.message;
if (!message) return null;
return (
<p className="inline-edit-error" role="alert">
{message}
</p>
);
}

View File

@@ -7,7 +7,13 @@
* @module * @module
*/ */
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import {
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts"; import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts";
import type { OnlineUser } from "../../../shared/ws.ts"; import type { OnlineUser } from "../../../shared/ws.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
@@ -24,11 +30,16 @@ function onMotionChange(callback: () => void): () => void {
return () => query.removeEventListener("change", callback); return () => query.removeEventListener("change", callback);
} }
// When the crawl began its current loop. It is kept out of the component, // Where the crawl had got to, and what its loop showed, when the marquee was
// which every page mounts anew with its header: the next page's marquee // last on screen. They are kept out of the component, which every page mounts
// carries on from where the last one's had got to, rather than from the // anew with its header: the next page's marquee carries on from that point of
// start. // that loop, rather than from the start. A crawl that was running has gone on
let loopStart: number | undefined; // since; one paused under the pointer, which has just clicked a link in it,
// hasn't.
let lastPosition:
| { elapsed: number; at: number; running: boolean }
| undefined;
let lastLoop: Loop | null = null;
/** What one loop of the crawl shows, from its start to its end. */ /** What one loop of the crawl shows, from its start to its end. */
interface Loop { interface Loop {
@@ -61,8 +72,12 @@ function FeedMarquee(): React.JSX.Element {
onMotionChange, onMotionChange,
() => matchMedia(REDUCED_MOTION).matches, () => matchMedia(REDUCED_MOTION).matches,
); );
const [loop, setLoop] = useState<Loop | null>(null); // A marquee with no feed yet is a first one, or follows a sign-out.
const [loop, setLoop] = useState<Loop | null>(() => feed && lastLoop);
if (!loop && feed) setLoop({ feed, online, turn: 0 }); if (!loop && feed) setLoop({ feed, online, turn: 0 });
useEffect(() => {
lastLoop = loop;
}, [loop]);
const shown = still ? feed && { feed, online } : loop; const shown = still ? feed && { feed, online } : loop;
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
@@ -71,16 +86,32 @@ function FeedMarquee(): React.JSX.Element {
const length = track?.firstElementChild?.getBoundingClientRect().width; const length = track?.firstElementChild?.getBoundingClientRect().width;
if (!track || !length) return; if (!track || !length) return;
track.style.setProperty("--marquee-length", String(Math.round(length))); track.style.setProperty("--marquee-length", String(Math.round(length)));
loopStart ??= performance.now(); const last = lastPosition;
lastPosition = undefined;
const elapsed = !last
? 0
: last.elapsed + (last.running ? performance.now() - last.at : 0);
// A negative delay starts the animation that far in. // A negative delay starts the animation that far in.
track.style.animationDelay = `${(loopStart - performance.now()) / 1000}s`; track.style.animationDelay = `${-elapsed / 1000}s`;
return () => {
// The track's own animation: the focus can have stopped it.
const animation = track.getAnimations()[0];
const timing = animation?.effect?.getComputedTiming();
if (!timing?.progress || typeof timing.duration !== "number") return;
lastPosition = {
elapsed: timing.progress * timing.duration,
at: performance.now(),
running: animation.playState === "running",
};
};
}, [loop?.turn, still]); }, [loop?.turn, still]);
return ( return (
<div <div
className="site-marquee" className="site-marquee"
// A focused link stops the crawl, and the browser scrolls the line to // A link focused from the keyboard stops the crawl, and the browser
// show it: the line goes back to its start once the focus has left. // scrolls the line to show it: the line goes back to its start once the
// focus has left.
onBlur={(e) => { onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) { if (!e.currentTarget.contains(e.relatedTarget)) {
e.currentTarget.scrollLeft = 0; e.currentTarget.scrollLeft = 0;
@@ -95,7 +126,6 @@ function FeedMarquee(): React.JSX.Element {
onAnimationIteration={(e) => { onAnimationIteration={(e) => {
// Not a child's animation ending a round of its own. // Not a child's animation ending a round of its own.
if (e.target !== e.currentTarget || !feed || !loop) return; if (e.target !== e.currentTarget || !feed || !loop) return;
loopStart = performance.now();
setLoop({ feed, online, turn: loop.turn + 1 }); setLoop({ feed, online, turn: loop.turn + 1 });
}} }}
> >

View File

@@ -8,12 +8,22 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import type { PlaylistWithDumps } from "../../../shared/playlist.ts"; import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type {
Playlist,
PlaylistWithDumps,
UpdatePlaylistRequest,
} from "../../../shared/playlist.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { deserializePlaylist } from "../../utils/shared/deserialize.ts";
import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts"; import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts"; import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { InlineEdit } from "../form/InlineEdit.tsx";
import { FollowPlaylistButton } from "../social/FollowButton.tsx"; import { FollowPlaylistButton } from "../social/FollowButton.tsx";
import { Tooltip } from "../shared/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx"; import { CategoryChips } from "../category/CategoryChips.tsx";
@@ -22,18 +32,38 @@ import { userPath } from "../../../shared/paths.ts";
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */ /** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
export interface PlaylistHeaderProps { export interface PlaylistHeaderProps {
playlist: PlaylistWithDumps; playlist: PlaylistWithDumps;
/** Given to those who may edit it, for whom it adds an "Edit" button. */ /**
* Given to those who may edit it, with `onSaved`: it adds an "Edit" button,
* which opens `PlaylistEditForm`.
*/
onEdit?: () => void; onEdit?: () => void;
/**
* Called with the playlist as the server has it once its title or
* description is edited in place, which it lets its editors do.
*/
onSaved?: (playlist: Playlist) => void;
} }
/** /**
* Renders the playlist's details, with a follow button for anyone but its * Renders the playlist's details, with a follow button for anyone but its
* owner. `PlaylistEditForm` takes its place while it is edited. * owner. Its editors change the title and the description in place, saved to
* `PUT /api/playlists/:playlistId`, and the rest in `PlaylistEditForm`, which
* takes the header's place while it is open.
*/ */
export function PlaylistHeader( export function PlaylistHeader(
{ playlist, onEdit }: PlaylistHeaderProps, { playlist, onEdit, onSaved }: PlaylistHeaderProps,
): React.JSX.Element { ): React.JSX.Element {
const { user, mediaToken } = useAuth(); const { user, mediaToken, authFetch } = useAuth();
const save = async (changes: UpdatePlaylistRequest) => {
const updated = await expectOk<Wire<Playlist>>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
}),
);
onSaved?.(deserializePlaylist(updated));
};
return ( return (
<> <>
{playlist.imageMime && ( {playlist.imageMime && (
@@ -48,7 +78,17 @@ export function PlaylistHeader(
)} )}
<div className="playlist-detail-content"> <div className="playlist-detail-content">
<div className="playlist-detail-title-row"> <div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1> <InlineEdit
value={playlist.title}
editable={!!onSaved}
label={t`Edit title`}
onSave={(title) => save({ title })}
required
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
fieldClassName="playlist-detail-title"
>
<h1 className="playlist-detail-title">{playlist.title}</h1>
</InlineEdit>
{user?.id !== playlist.userId && ( {user?.id !== playlist.userId && (
<FollowPlaylistButton <FollowPlaylistButton
targetPlaylistId={playlist.id} targetPlaylistId={playlist.id}
@@ -66,10 +106,29 @@ export function PlaylistHeader(
)} )}
</div> </div>
{playlist.description && ( {(playlist.description || onSaved) && (
<Markdown className="playlist-detail-description"> <InlineEdit
{playlist.description} value={playlist.description ?? ""}
</Markdown> editable={!!onSaved}
label={t`Edit description`}
onSave={(description) => save({ description: description || null })}
kind="markdown"
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
placeholder={t`Add a description…`}
>
{playlist.description
? (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)
: (
<p className="inline-edit-empty">
<Trans>Add a description…</Trans>
</p>
)}
</InlineEdit>
)} )}
<div className="playlist-detail-meta"> <div className="playlist-detail-meta">

View File

@@ -1,104 +0,0 @@
/**
* Renders the inline editor of the signed-in user's description, under their
* profile's header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { TextEditor } from "../editor/TextEditor.tsx";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link DescriptionEditor} by `ProfileDescription`, which shows it
* in place of the description while editing.
*/
export interface DescriptionEditorProps {
initialDescription: string;
onCancel: () => void;
onSaved: (description: string | undefined) => void;
}
/**
* Renders the description's Markdown editor, saved to `PATCH /api/me`.
* Other browsers see the change through the socket's `user_updated` event.
*/
export function DescriptionEditor(
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
): React.JSX.Element {
const { authFetch } = useAuth();
const form = useApiForm<{ description: string }>({
defaultValues: { description: initialDescription },
});
const description = form.watch("description");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ description }) => {
const trimmed = description.trim();
// Sent even when empty, since the server keeps the old one when it is left
// out.
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ description: trimmed } satisfies UpdateUserRequest,
),
}),
);
onSaved(trimmed || undefined);
});
return (
<FormProvider {...form}>
<form
className="profile-description-editor animate-in"
onSubmit={onSubmit}
>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="comment-reply-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
onCancel={submitting ? undefined : onCancel}
placeholder={t`Who am I?`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/>
)}
/>
<div className="profile-description-actions">
<SubmitButton
pendingLabel={<Trans>Saving…</Trans>}
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<FormError title={t`Failed to save`} />
</div>
</form>
</FormProvider>
);
}

View File

@@ -1,90 +0,0 @@
/**
* Renders the inline editor of the signed-in user's email, in their profile's
* header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link EmailEditor} by `ProfileHeader`, which shows it in place of
* the email while editing.
*/
export interface EmailEditorProps {
initialEmail: string;
onCancel: () => void;
onSaved: (email: string) => void;
}
/**
* Renders the email field, saved to `PATCH /api/me`. Escape cancels.
*/
export function EmailEditor(
{ initialEmail, onCancel, onSaved }: EmailEditorProps,
): React.JSX.Element {
const { authFetch } = useAuth();
const form = useApiForm<{ email: string }>({
defaultValues: { email: initialEmail },
});
const email = form.watch("email");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ email }) => {
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ email: email.trim() } satisfies UpdateUserRequest,
),
}),
);
onSaved(email.trim());
});
return (
<FormProvider {...form}>
<form className="profile-email-editor animate-in" onSubmit={onSubmit}>
<input
type="email"
className="profile-email-input"
{...form.register("email")}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
disabled={submitting}
autoFocus
/>
<div className="profile-email-actions">
<SubmitButton
className="btn-primary btn-sm"
pendingLabel={<Trans>Saving…</Trans>}
disabled={!email.trim()}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary btn-sm"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
</div>
<FormError title={t`Failed to save`} />
</form>
</FormProvider>
);
}

View File

@@ -5,12 +5,16 @@
* @module * @module
*/ */
import { useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts"; import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { InlineEdit } from "../form/InlineEdit.tsx";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { DescriptionEditor } from "./DescriptionEditor.tsx";
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */ /** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
export interface ProfileDescriptionProps { export interface ProfileDescriptionProps {
@@ -22,70 +26,53 @@ export interface ProfileDescriptionProps {
/** /**
* Renders the description in Markdown, or nothing when there is none. On * Renders the description in Markdown, or nothing when there is none. On
* their own profile, users get a "Who am I?" placeholder instead, and a click * their own profile, users get a "Who am I?" placeholder instead, and edit it
* or Enter swaps in {@link DescriptionEditor}. * in place, saved to `PATCH /api/me`. Other browsers see the change through
* the socket's `user_updated` event.
*/ */
export function ProfileDescription( export function ProfileDescription(
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps, { description, isOwnProfile, onSaved }: ProfileDescriptionProps,
): React.JSX.Element | null { ): React.JSX.Element | null {
const [editing, setEditing] = useState(false); const { authFetch } = useAuth();
const ref = useHeightTransition<HTMLDivElement>(editing);
if (!description && !isOwnProfile) return null; if (!description && !isOwnProfile) return null;
return ( return (
<div className="profile-description" ref={ref}> <div className="profile-description">
{editing <InlineEdit
? ( value={description ?? ""}
<DescriptionEditor editable={isOwnProfile}
initialDescription={description ?? ""} label={t`Edit description`}
onCancel={() => setEditing(false)} onSave={async (saved) => {
onSaved={(saved) => { // Sent even when empty, since the server keeps the old one when it
onSaved(saved); // is left out.
setEditing(false); await expectOk<Wire<PublicUser>>(
}} await authFetch(`${API_URL}/api/me`, {
/> method: "PATCH",
) headers: { "Content-Type": "application/json" },
: ( body: JSON.stringify(
<div { description: saved } satisfies UpdateUserRequest,
className={`profile-description-view${ ),
isOwnProfile ? " profile-description-view--editable" : "" }),
}`} );
onClick={isOwnProfile ? () => setEditing(true) : undefined} onSaved(saved || undefined);
// Focusable rather than a button, so the Markdown inside keeps its }}
// own structure and links. kind="markdown"
tabIndex={isOwnProfile ? 0 : undefined} maxLength={VALIDATION.USER_DESCRIPTION_MAX}
title={isOwnProfile ? t`Edit description` : undefined} placeholder={t`Who am I?`}
onKeyDown={isOwnProfile >
? (e) => { {description
if ( ? (
(e.key === "Enter" || e.key === " ") && <Markdown className="profile-description-text">
e.target === e.currentTarget {description}
) { </Markdown>
e.preventDefault(); )
setEditing(true); : (
} <p className="inline-edit-empty">
} <Trans>Who am I?</Trans>
: undefined} </p>
> )}
{description </InlineEdit>
? (
<Markdown className="profile-description-text">
{description}
</Markdown>
)
: (
<div className="profile-description-empty">
<Trans>Who am I?</Trans>
</div>
)}
{isOwnProfile && (
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -11,15 +11,18 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts"; import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, SelfUser } from "../../../shared/user.ts"; import type {
import { parseAPIResponse } from "../../utils/shared/api.ts"; PublicUser,
SelfUser,
UpdateUserRequest,
} from "../../../shared/user.ts";
import { expectOk, parseAPIResponse } from "../../utils/shared/api.ts";
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts"; import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { Avatar } from "../shared/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx";
import { FollowUserButton } from "../social/FollowButton.tsx"; import { FollowUserButton } from "../social/FollowButton.tsx";
import { EmailEditor } from "./EmailEditor.tsx"; import { InlineEdit } from "../form/InlineEdit.tsx";
import { ProfileOwnActions } from "./ProfileOwnActions.tsx"; import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
import { RoleChip } from "./RoleSwitcher.tsx"; import { RoleChip } from "./RoleSwitcher.tsx";
import { userPath } from "../../../shared/paths.ts"; import { userPath } from "../../../shared/paths.ts";
@@ -38,7 +41,7 @@ export interface ProfileHeaderProps {
* Renders the avatar, username, role and inviter, and for a user from another * Renders the avatar, username, role and inviter, and for a user from another
* instance, a link to their profile there. Visitors get a follow button. On * instance, a link to their profile there. Visitors get a follow button. On
* their own profile, users change their avatar (`PUT /api/me/avatar`) and * their own profile, users change their avatar (`PUT /api/me/avatar`) and
* email, unless their account comes from another instance, and invite * email (`PATCH /api/me`), unless their account comes from another instance, and invite
* someone; their settings, the site's tools and logging out are in the * someone; their settings, the site's tools and logging out are in the
* header's avatar menu. * header's avatar menu.
*/ */
@@ -49,8 +52,6 @@ export function ProfileHeader(
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null); const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [emailEditing, setEmailEditing] = useState(false);
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
const { name, host } = splitHandle(user.username); const { name, host } = splitHandle(user.username);
// A user from another instance changes their avatar and email there. // A user from another instance changes their avatar and email there.
const editable = isOwnProfile && !host; const editable = isOwnProfile && !host;
@@ -83,9 +84,15 @@ export function ProfileHeader(
} }
}; };
const handleEmailSaved = (email: string) => { const handleEmailSave = async (email: string) => {
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email } satisfies UpdateUserRequest),
}),
);
if (me) updateSession({ user: { ...me, email } }); if (me) updateSession({ user: { ...me, email } });
setEmailEditing(false);
}; };
return ( return (
@@ -112,7 +119,7 @@ export function ProfileHeader(
</label> </label>
)} )}
</div> </div>
<div className="profile-info" ref={infoRef}> <div className="profile-info">
<div className="profile-username-row"> <div className="profile-username-row">
<h1 className="profile-username"> <h1 className="profile-username">
{name} {name}
@@ -148,34 +155,19 @@ export function ProfileHeader(
</p> </p>
)} )}
{editable && ( {editable && (
emailEditing <InlineEdit
? ( value={me?.email ?? ""}
<EmailEditor editable
initialEmail={me?.email ?? ""} label={t`Edit email`}
onCancel={() => setEmailEditing(false)} onSave={handleEmailSave}
onSaved={handleEmailSaved} kind="email"
/> required
) className="profile-email"
: ( >
<p <span className="profile-email-text">
className="profile-email-display" {me?.email ?? t`Add email…`}
onClick={() => setEmailEditing(true)} </span>
onKeyDown={(e) => { </InlineEdit>
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEmailEditing(true);
}
}}
tabIndex={0}
role="button"
title={t`Edit email`}
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
</p>
)
)} )}
{avatarError && ( {avatarError && (
<ErrorCard <ErrorCard

View File

@@ -33,12 +33,15 @@ import {
deserializePlaylist, deserializePlaylist,
deserializePublicUser, deserializePublicUser,
} from "../utils/shared/deserialize.ts"; } from "../utils/shared/deserialize.ts";
import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts"; import {
expectOk,
friendlyFetchError,
parseAPIResponse,
} from "../utils/shared/api.ts";
import { useAuth } from "../hooks/auth/useAuth.ts"; import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts"; import { relativeTime } from "../utils/shared/relativeTime.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts"; import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { Avatar } from "../components/shared/Avatar.tsx"; import { Avatar } from "../components/shared/Avatar.tsx";
@@ -52,6 +55,7 @@ import { PageError } from "../components/layout/PageError.tsx";
import { Markdown } from "../components/editor/Markdown.tsx"; import { Markdown } from "../components/editor/Markdown.tsx";
import { CommentThread } from "../components/comment/CommentThread.tsx"; import { CommentThread } from "../components/comment/CommentThread.tsx";
import { Tooltip } from "../components/shared/Tooltip.tsx"; import { Tooltip } from "../components/shared/Tooltip.tsx";
import { InlineEdit } from "../components/form/InlineEdit.tsx";
import { userPath } from "../../shared/paths.ts"; import { userPath } from "../../shared/paths.ts";
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts"; import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
@@ -67,9 +71,10 @@ type DumpState =
* `DumpEdit` or a title edit here just saved it. Reached by a former name or * `DumpEdit` or a title edit here just saved it. Reached by a former name or
* slug, the URL takes the current ones. Its comments, related dumps and * slug, the URL takes the current ones. Its comments, related dumps and
* playlists come from their own endpoints once it is loaded, and the socket's * playlists come from their own endpoints once it is loaded, and the socket's
* events keep them current; a reconnection fetches them and the dump again. A dump deleted, or made private by its poster, while someone else * events keep them current; a reconnection fetches them and the dump again.
* views it gives way to a notice. The poster and moderators can edit the * A dump deleted, or made private by its poster, while someone else views it
* title in place, and get a link to `DumpEdit`. * gives way to a notice. The poster and moderators can edit the title and the
* note in place, and get a link to `DumpEdit` for the rest.
*/ */
export function Dump(): React.JSX.Element { export function Dump(): React.JSX.Element {
const { handle, slug } = useParams(); const { handle, slug } = useParams();
@@ -129,12 +134,6 @@ export function Dump(): React.JSX.Element {
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]); const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
const [collections, setCollections] = useState<Playlist[]>([]); const [collections, setCollections] = useState<Playlist[]>([]);
const [titleEditing, setTitleEditing] = useState(false);
const headerRightRef = useHeightTransition<HTMLDivElement>(titleEditing);
const [titleDraft, setTitleDraft] = useState("");
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState<string | null>(null);
const { user, mediaToken, authFetch } = useAuth(); const { user, mediaToken, authFetch } = useAuth();
const { const {
voteCounts, voteCounts,
@@ -409,45 +408,23 @@ export function Dump(): React.JSX.Element {
const canEdit = !!user && const canEdit = !!user &&
(dump.userId === user.id || can(user, "dump:moderate")); (dump.userId === user.id || can(user, "dump:moderate"));
const startTitleEdit = () => { // Saves what the title or the note was changed to in place. The page stays
setTitleDraft(dump.title); // mounted across a slug change, and would keep showing the dump it had.
setTitleError(null); const saveDump = async (changes: UpdateDumpRequest) => {
setTitleEditing(true); const updatedDump = deserializeDump(
}; await expectOk<Wire<Dump>>(
await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
const handleTitleSave = async () => { method: "PUT",
const trimmed = titleDraft.trim(); body: JSON.stringify(changes),
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return; }),
),
setTitleSaving(true); );
setTitleError(null); setDumpState({ status: "loaded", dump: updatedDump });
try { if (updatedDump.slug !== dump.slug) {
const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, { navigate(dumpUrl(updatedDump), {
method: "PUT", replace: true,
body: JSON.stringify( state: { dump: updatedDump },
{ title: trimmed } satisfies UpdateDumpRequest,
),
}); });
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
if (!apiResponse.success) {
setTitleError(apiResponse.error.message);
return;
}
const updatedDump = deserializeDump(apiResponse.data);
setTitleEditing(false);
// The page stays mounted across the slug change below, and would keep
// the old title.
setDumpState({ status: "loaded", dump: updatedDump });
if (updatedDump.slug !== dump.slug) {
navigate(dumpUrl(updatedDump), {
replace: true,
state: { dump: updatedDump },
});
}
} catch (err) {
setTitleError(friendlyFetchError(err));
} finally {
setTitleSaving(false);
} }
}; };
@@ -475,79 +452,18 @@ export function Dump(): React.JSX.Element {
</button> </button>
)} )}
</div> </div>
<div className="dump-header-right" ref={headerRightRef}> <div className="dump-header-right">
{titleEditing <InlineEdit
? ( value={dump.title}
<div className="dump-title-editor animate-in"> editable={canEdit}
<input label={t`Edit title`}
type="text" onSave={(title) => saveDump({ title })}
className="dump-title-input" required
value={titleDraft} maxLength={VALIDATION.DUMP_TITLE_MAX}
onChange={(e) => setTitleDraft(e.target.value)} fieldClassName="dump-title"
onKeyDown={(e) => { >
if (e.key === "Enter") { <h1 className="dump-title">{dump.title}</h1>
e.preventDefault(); </InlineEdit>
handleTitleSave();
} else if (e.key === "Escape") {
setTitleEditing(false);
}
}}
maxLength={VALIDATION.DUMP_TITLE_MAX}
disabled={titleSaving}
autoFocus
/>
<div className="dump-title-actions">
<button
type="button"
className="btn-primary"
onClick={handleTitleSave}
disabled={titleSaving || !titleDraft.trim()}
>
{titleSaving
? <Trans>Saving…</Trans>
: <Trans>Save</Trans>}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setTitleEditing(false)}
disabled={titleSaving}
>
<Trans>Cancel</Trans>
</button>
</div>
{titleError && (
<p className="dump-title-error">{titleError}</p>
)}
</div>
)
: (
<div
className={`dump-title-view${
canEdit ? " dump-title-view--editable" : ""
}`}
onClick={canEdit ? startTitleEdit : undefined}
// Reachable from the keyboard too; not a button, so the
// title inside stays a heading.
tabIndex={canEdit ? 0 : undefined}
title={canEdit ? t`Edit title` : undefined}
onKeyDown={canEdit
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
startTitleEdit();
}
}
: undefined}
>
<h1 className="dump-title">{dump.title}</h1>
{canEdit && (
<span className="dump-title-edit-btn" aria-hidden>
✎
</span>
)}
</div>
)}
<div className="dump-op"> <div className="dump-op">
<Avatar <Avatar
userId={dump.userId} userId={dump.userId}
@@ -587,8 +503,25 @@ export function Dump(): React.JSX.Element {
</div> </div>
</div> </div>
{dump.comment && ( {(dump.comment || canEdit) && (
<Markdown className="dump-comment">{dump.comment}</Markdown> <InlineEdit
value={dump.comment ?? ""}
editable={canEdit}
label={t`Edit note`}
onSave={(comment) => saveDump({ comment: comment || null })}
kind="markdown"
maxLength={VALIDATION.DUMP_COMMENT_MAX}
placeholder={t`What makes it worth it?`}
>
{dump.comment
? <Markdown className="dump-comment">{dump.comment}</Markdown>
: (
<p className="inline-edit-empty">
<Trans>What makes it worth it?</Trans>
</p>
)}
</InlineEdit>
)} )}
</div> </div>

View File

@@ -36,7 +36,8 @@ export interface PlaylistDetailProps {
* a private one only to its owner. `usePlaylist` loads it, by a former name or * a private one only to its owner. `usePlaylist` loads it, by a former name or
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`, * slug too, and keeps it live. Its owner, or a user with `playlist:moderate`,
* can drag its dumps into a new order and remove one, with a short window to * can drag its dumps into a new order and remove one, with a short window to
* undo, and edit or delete it in the editor that opens over its header at * undo, edit its title and description in place, and edit the rest or delete
* it in the editor that opens over its header at
* `/@:username/playlists/:slug/edit`. Both paths render the page at the same * `/@:username/playlists/:slug/edit`. Both paths render the page at the same
* place, so opening the editor keeps the playlist loaded. Anyone else there * place, so opening the editor keeps the playlist loaded. Anyone else there
* gets the page, or is asked to sign in first. * gets the page, or is asked to sign in first.
@@ -143,9 +144,10 @@ export function PlaylistDetail(
<div className="playlist-detail-header-top"> <div className="playlist-detail-header-top">
<PlaylistHeader <PlaylistHeader
playlist={playlist} playlist={playlist}
onEdit={canEdit {...(canEdit && {
? () => navigate(playlistUrl(playlist, "edit")) onEdit: () => navigate(playlistUrl(playlist, "edit")),
: undefined} onSaved: applyDetails,
})}
/> />
</div> </div>
)} )}

View File

@@ -0,0 +1,88 @@
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { InlineEdit } from "../../../components/form/InlineEdit.tsx";
function renderEdit(
props: Partial<React.ComponentProps<typeof InlineEdit>> = {},
) {
const onSave = vi.fn(async (_: string) => {});
const user = userEvent.setup();
render(
<I18nProvider i18n={i18n}>
<InlineEdit
value="Old"
editable
label="Edit name"
onSave={onSave}
required
{...props}
>
<h2>
Old <a href="#elsewhere">link</a>
</h2>
</InlineEdit>
</I18nProvider>,
);
return { user, onSave };
}
describe("InlineEdit", () => {
test("without the right to edit, only the text shows", () => {
renderEdit({ editable: false });
expect(screen.getByRole("heading")).toBeInTheDocument();
expect(screen.queryByRole("button")).toBeNull();
});
test("a click on the text opens the field; Enter saves it trimmed", async () => {
const { user, onSave } = renderEdit();
await user.click(screen.getByRole("heading"));
const input = screen.getByRole("textbox", { name: "Edit name" });
expect(input).toHaveFocus();
await user.type(input, "er {Enter}");
expect(onSave).toHaveBeenCalledWith("Older");
expect(screen.getByRole("heading")).toBeInTheDocument();
});
test("a link in the text is followed, not edited", async () => {
const { user } = renderEdit();
await user.click(screen.getByRole("link", { name: "link" }));
expect(screen.queryByRole("textbox")).toBeNull();
});
test("left as it was, nothing is sent", async () => {
const { user, onSave } = renderEdit();
await user.click(screen.getByRole("button", { name: "Edit name" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).not.toHaveBeenCalled();
expect(screen.queryByRole("textbox")).toBeNull();
});
test("an empty required text can't be saved", async () => {
const { user } = renderEdit();
await user.click(screen.getByRole("button", { name: "Edit name" }));
await user.clear(screen.getByRole("textbox"));
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
test("Escape backs out, and the focus goes back to the ✎", async () => {
const { user, onSave } = renderEdit();
screen.getByRole("button", { name: "Edit name" }).focus();
await user.keyboard("{Enter}");
await user.type(screen.getByRole("textbox"), "x{Escape}");
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Edit name" })).toHaveFocus();
});
test("a failed save stays open and says why", async () => {
const { user } = renderEdit({
onSave: () => Promise.reject(new Error("Taken")),
});
await user.click(screen.getByRole("heading"));
await user.type(screen.getByRole("textbox"), "!{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Taken");
expect(screen.getByRole("textbox")).toHaveValue("Old!");
});
});

View File

@@ -135,6 +135,35 @@ describe("Dump page", () => {
expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument();
}); });
test("the owner writes the note in place; emptied, it goes", async () => {
dumpRoutes();
route(
"PUT /api/dumps/:id",
() =>
wire(makeDump({
id: "d1",
slug: "a-find",
title: "A find",
userId: "owner",
comment: undefined,
})),
);
const { user } = openDump(owner);
await user.click(await screen.findByText("reasons"));
await user.clear(screen.getByDisplayValue("Because **reasons**"));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("What makes it worth it?"))
.toBeInTheDocument();
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ comment: null });
});
test("a visitor sees no note where there is none", async () => {
dumpRoutes(makeDump({ slug: "a-find", title: "A find", userId: "owner" }));
openDump(other);
await screen.findByRole("heading", { name: "A find" });
expect(screen.queryByText("What makes it worth it?")).toBeNull();
});
test("others can't rename or edit it", async () => { test("others can't rename or edit it", async () => {
dumpRoutes(); dumpRoutes();
const { user } = openDump(other); const { user } = openDump(other);
@@ -157,7 +186,7 @@ describe("Dump page", () => {
dumpRoutes(); dumpRoutes();
const { user } = openDump(owner); const { user } = openDump(owner);
await screen.findByRole("heading", { name: "A find" }); await screen.findByRole("heading", { name: "A find" });
screen.getByTitle("Edit title").focus(); screen.getByRole("button", { name: "Edit title" }).focus();
await user.keyboard("{Enter}"); await user.keyboard("{Enter}");
expect(screen.getByDisplayValue("A find")).toBeInTheDocument(); expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
}); });

View File

@@ -100,6 +100,7 @@ describe("PlaylistDetail", () => {
.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit title" })).toBeNull();
}); });
test("the cover is versioned on the last edit", async () => { test("the cover is versioned on the last edit", async () => {
@@ -140,6 +141,29 @@ describe("PlaylistDetail", () => {
}); });
}); });
test("its owner retitles it in place, which keeps its link", async () => {
route(
"PUT /api/playlists/:id",
() =>
wire(makePlaylist({
id: "p1",
slug: "mix",
title: "Remix",
userId: "owner",
description: "Good **tunes**",
})),
);
const { user } = open();
await user.click(await screen.findByRole("heading", { name: "Mix" }));
const input = screen.getByDisplayValue("Mix");
await user.clear(input);
await user.type(input, "Remix{Enter}");
expect(await screen.findByRole("heading", { name: "Remix" }))
.toBeInTheDocument();
expect(currentPath()).toBe("/@owner/playlists/mix");
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ title: "Remix" });
});
test("the editor is a page: /edit opens it, closing goes back to the page", async () => { test("the editor is a page: /edit opens it, closing goes back to the page", async () => {
const { user } = open({ url: "/@owner/playlists/mix/edit" }); const { user } = open({ url: "/@owner/playlists/mix/edit" });
expect(await screen.findByRole("textbox", { name: "Description" })) expect(await screen.findByRole("textbox", { name: "Description" }))

View File

@@ -66,7 +66,8 @@ describe("UserPublicProfile", () => {
`${API_URL}/api/users/@alice%40home.example/home`, `${API_URL}/api/users/@alice%40home.example/home`,
); );
expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument(); expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument();
expect(screen.queryByTitle("Edit email")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Edit email" })).not
.toBeInTheDocument();
expect(screen.queryByText("O.G.")).not.toBeInTheDocument(); expect(screen.queryByText("O.G.")).not.toBeInTheDocument();
expect(calls("GET /api/users/:u")[0].path).toBe( expect(calls("GET /api/users/:u")[0].path).toBe(
"/api/users/@alice%40home.example", "/api/users/@alice%40home.example",
@@ -451,10 +452,8 @@ describe("UserPublicProfile", () => {
(req) => wire({ ...ann, ...(req.body as object) }), (req) => wire({ ...ann, ...(req.body as object) }),
); );
const { user } = open({ session: annSession }); const { user } = open({ session: annSession });
const view = (await screen.findByText("dump")).closest( await screen.findByText("dump");
"[title='Edit description']", screen.getByRole("button", { name: "Edit description" }).focus();
) as HTMLElement;
view.focus();
await user.keyboard("{Enter}"); await user.keyboard("{Enter}");
const box = screen.getByPlaceholderText("Who am I?"); const box = screen.getByPlaceholderText("Who am I?");
await user.clear(box); await user.clear(box);
@@ -469,14 +468,11 @@ describe("UserPublicProfile", () => {
(req) => wire({ ...ann, ...(req.body as object) }), (req) => wire({ ...ann, ...(req.body as object) }),
); );
const { user } = open({ session: annSession }); const { user } = open({ session: annSession });
await user.click( await user.click(await screen.findByText("ann@x.test"));
await screen.findByRole("button", { name: /ann@x\.test/ }),
);
const input = screen.getByDisplayValue("ann@x.test"); const input = screen.getByDisplayValue("ann@x.test");
await user.clear(input); await user.clear(input);
await user.type(input, "new@x.test{Enter}"); await user.type(input, "new@x.test{Enter}");
expect(await screen.findByRole("button", { name: /new@x\.test/ })) expect(await screen.findByText("new@x.test")).toBeInTheDocument();
.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email) expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
.toBe("new@x.test"); .toBe("new@x.test");
}); });
@@ -484,9 +480,7 @@ describe("UserPublicProfile", () => {
test("a refused email says why", async () => { test("a refused email says why", async () => {
route("PATCH /api/me", () => fail("Email already in use", 409)); route("PATCH /api/me", () => fail("Email already in use", 409));
const { user } = open({ session: annSession }); const { user } = open({ session: annSession });
await user.click( await user.click(await screen.findByText("ann@x.test"));
await screen.findByRole("button", { name: /ann@x\.test/ }),
);
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}"); await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
"Email already in use", "Email already in use",

View File

@@ -172,7 +172,8 @@
[data-style="brutalist"] .user-menu-trigger, [data-style="brutalist"] .user-menu-trigger,
[data-style="brutalist"] .user-menu-item, [data-style="brutalist"] .user-menu-item,
[data-style="brutalist"] .form-cancel, [data-style="brutalist"] .form-cancel,
[data-style="brutalist"] .form-label-action { [data-style="brutalist"] .form-label-action,
[data-style="brutalist"] .inline-edit-mark {
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
@@ -184,7 +185,9 @@
[data-style="brutalist"] .form-cancel:hover:not(:disabled), [data-style="brutalist"] .form-cancel:hover:not(:disabled),
[data-style="brutalist"] .form-cancel:active, [data-style="brutalist"] .form-cancel:active,
[data-style="brutalist"] .form-label-action:hover:not(:disabled), [data-style="brutalist"] .form-label-action:hover:not(:disabled),
[data-style="brutalist"] .form-label-action:active { [data-style="brutalist"] .form-label-action:active,
[data-style="brutalist"] .inline-edit-mark:hover:not(:disabled),
[data-style="brutalist"] .inline-edit-mark:active {
transform: none !important; transform: none !important;
box-shadow: none !important; box-shadow: none !important;
} }

View File

@@ -308,7 +308,7 @@
/* Pressed: the bevel turns inside out and the label moves down and right a /* Pressed: the bevel turns inside out and the label moves down and right a
pixel with the face. */ pixel with the face. */
[data-style="geocities"] button:not(.dump-fab, .chat-fab, .btn--ghost, .user-menu-trigger, .user-menu-item):active:not(:disabled, [aria-disabled="true"]), [data-style="geocities"] button:not(.dump-fab, .chat-fab, .btn--ghost, .user-menu-trigger, .user-menu-item, .inline-edit-mark):active:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .btn-primary:active:not(:disabled, [aria-disabled="true"]), [data-style="geocities"] .btn-primary:active:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .btn-secondary:active:not(:disabled, [aria-disabled="true"]), [data-style="geocities"] .btn-secondary:active:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .btn-border:active:not(:disabled, [aria-disabled="true"]), [data-style="geocities"] .btn-border:active:not(:disabled, [aria-disabled="true"]),
@@ -653,13 +653,16 @@
opacity: 0.75; opacity: 0.75;
} }
/* The crawl stops under the pointer, and for a link that has the focus: the /* The crawl stops under the pointer, and for a link the keyboard has
line then stands at its start, and the browser scrolls it to that link. */ focused: the line then stands at its start, and the browser scrolls it to
that link. A link the pointer presses takes the focus too, but is left
where it is, paused under the pointer: brought back to the start, it would
slip away before the button came up, and the click with it. */
[data-style="geocities"] .site-marquee:hover .site-marquee-track { [data-style="geocities"] .site-marquee:hover .site-marquee-track {
animation-play-state: paused; animation-play-state: paused;
} }
[data-style="geocities"] .site-marquee:focus-within .site-marquee-track { [data-style="geocities"] .site-marquee:has(:focus-visible) .site-marquee-track {
animation: none; animation: none;
} }

View File

@@ -126,7 +126,7 @@
.form-field, .form-field,
.profile-role-chip, .profile-role-chip,
.profile-invited-by, .profile-invited-by,
.profile-email-display, .profile-email,
.profile-section-header, .profile-section-header,
.profile-appearance-row, .profile-appearance-row,
.playlist-badge, .playlist-badge,