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:
File diff suppressed because one or more lines are too long
103
locales/en.po
103
locales/en.po
@@ -206,14 +206,14 @@ msgstr "# Welcome to {site}!\n"
|
||||
"Your account has been created successfully. Welcome aboard!"
|
||||
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:736
|
||||
#: src/pages/PlaylistDetail.tsx:98
|
||||
#: src/pages/PlaylistDetail.tsx:99
|
||||
#: src/pages/UserPublicProfile.tsx:129
|
||||
msgid "← Back"
|
||||
msgstr "← Back"
|
||||
|
||||
#: src/pages/Dump.tsx:375
|
||||
#: src/pages/Dump.tsx:400
|
||||
#: src/pages/Dump.tsx:627
|
||||
#: src/pages/Dump.tsx:374
|
||||
#: src/pages/Dump.tsx:399
|
||||
#: src/pages/Dump.tsx:560
|
||||
#: src/pages/DumpEdit.tsx:234
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Back to all dumps"
|
||||
@@ -226,7 +226,7 @@ msgstr "+ Invite someone"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ New playlist"
|
||||
|
||||
#: src/pages/Dump.tsx:474
|
||||
#: src/pages/Dump.tsx:451
|
||||
msgid "+ 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>"
|
||||
|
||||
#. placeholder {0}: event.username
|
||||
#: src/components/social/CommunityFeed.tsx:144
|
||||
#: src/components/social/CommunityFeed.tsx:145
|
||||
msgid "<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."
|
||||
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}"
|
||||
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>"
|
||||
msgstr "<0>This week:</0> <1>{figures}</1>"
|
||||
|
||||
#. 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>"
|
||||
msgstr "<0>Top this week:</0> <1>{0}</1>"
|
||||
|
||||
@@ -344,7 +344,7 @@ msgstr "a post"
|
||||
msgid "Account"
|
||||
msgstr "Account"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:144
|
||||
#: src/components/profile/ProfileHeader.tsx:151
|
||||
msgid "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…"
|
||||
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:116
|
||||
msgid "Add category"
|
||||
msgstr "Add category"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:173
|
||||
#: src/components/profile/ProfileHeader.tsx:168
|
||||
msgid "Add email…"
|
||||
msgstr "Add email…"
|
||||
|
||||
@@ -470,12 +475,10 @@ msgstr "Can't sign in there"
|
||||
#: src/components/comment/CommentForm.tsx:127
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:746
|
||||
#: src/components/form/FormActions.tsx:42
|
||||
#: src/components/form/InlineEdit.tsx:277
|
||||
#: 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/pages/Authorize.tsx:158
|
||||
#: src/pages/Dump.tsx:516
|
||||
#: src/pages/DumpEdit.tsx:561
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
@@ -501,7 +504,7 @@ msgstr "Categories"
|
||||
msgid "Category"
|
||||
msgstr "Category"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:102
|
||||
#: src/components/profile/ProfileHeader.tsx:109
|
||||
msgid "Change avatar"
|
||||
msgstr "Change avatar"
|
||||
|
||||
@@ -721,7 +724,7 @@ msgstr "Delete failed ({0})"
|
||||
#: src/components/playlist/PlaylistCard.tsx:172
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||
#: src/pages/PlaylistDetail.tsx:172
|
||||
#: src/pages/PlaylistDetail.tsx:174
|
||||
msgid "Delete playlist"
|
||||
msgstr "Delete playlist"
|
||||
|
||||
@@ -738,7 +741,7 @@ msgid "Delete this message?"
|
||||
msgstr "Delete this message?"
|
||||
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||
#: src/pages/PlaylistDetail.tsx:171
|
||||
#: src/pages/PlaylistDetail.tsx:173
|
||||
msgid "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/PendingItem.tsx:93
|
||||
#: src/components/comment/CommentNode.tsx:256
|
||||
#: src/components/playlist/PlaylistHeader.tsx:64
|
||||
#: src/pages/Dump.tsx:623
|
||||
#: src/components/playlist/PlaylistHeader.tsx:104
|
||||
#: src/pages/Dump.tsx:556
|
||||
msgid "Edit"
|
||||
msgstr "Edit"
|
||||
|
||||
@@ -837,16 +840,22 @@ msgstr "Edit"
|
||||
msgid "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"
|
||||
msgstr "Edit description"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:171
|
||||
#: src/components/profile/ProfileHeader.tsx:161
|
||||
msgid "Edit email"
|
||||
msgstr "Edit email"
|
||||
|
||||
#: src/pages/Dump.tsx:510
|
||||
msgid "Edit note"
|
||||
msgstr "Edit note"
|
||||
|
||||
#: 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"
|
||||
msgstr "Edit title"
|
||||
|
||||
@@ -859,8 +868,8 @@ msgstr "edited"
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/chat/ChatMessageItem.tsx:132
|
||||
#: src/components/comment/CommentNode.tsx:205
|
||||
#: src/components/playlist/PlaylistHeader.tsx:99
|
||||
#: src/pages/Dump.tsx:576
|
||||
#: src/components/playlist/PlaylistHeader.tsx:158
|
||||
#: src/pages/Dump.tsx:492
|
||||
msgid "edited {0}"
|
||||
msgstr "edited {0}"
|
||||
|
||||
@@ -869,8 +878,8 @@ msgstr "edited {0}"
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/chat/ChatMessageItem.tsx:128
|
||||
#: src/components/comment/CommentNode.tsx:203
|
||||
#: src/components/playlist/PlaylistHeader.tsx:97
|
||||
#: src/pages/Dump.tsx:574
|
||||
#: src/components/playlist/PlaylistHeader.tsx:156
|
||||
#: src/pages/Dump.tsx:490
|
||||
msgid "Edited {0}"
|
||||
msgstr "Edited {0}"
|
||||
|
||||
@@ -936,8 +945,6 @@ msgstr "Failed to post reply"
|
||||
#: src/components/federation/GrantList.tsx:98
|
||||
#: src/components/federation/PeerManager.tsx:158
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:207
|
||||
#: src/components/profile/DescriptionEditor.tsx:99
|
||||
#: src/components/profile/EmailEditor.tsx:86
|
||||
msgid "Failed to save"
|
||||
msgstr "Failed to save"
|
||||
|
||||
@@ -949,7 +956,7 @@ msgstr "Failed to save category"
|
||||
msgid "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"
|
||||
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"
|
||||
msgstr "image"
|
||||
|
||||
#: src/pages/Dump.tsx:643
|
||||
#: src/pages/Dump.tsx:576
|
||||
msgid "In collections"
|
||||
msgstr "In collections"
|
||||
|
||||
@@ -1095,7 +1102,7 @@ msgstr "Invalid link"
|
||||
msgid "Invite link"
|
||||
msgstr "Invite link"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:126
|
||||
#: src/components/profile/ProfileHeader.tsx:133
|
||||
msgid "invited by"
|
||||
msgstr "invited by"
|
||||
|
||||
@@ -1165,7 +1172,7 @@ msgstr "Load more"
|
||||
msgid "Load older messages"
|
||||
msgstr "Load older messages"
|
||||
|
||||
#: src/pages/Dump.tsx:359
|
||||
#: src/pages/Dump.tsx:358
|
||||
#: src/pages/DumpEdit.tsx:210
|
||||
msgid "Loading dump…"
|
||||
msgstr "Loading dump…"
|
||||
@@ -1178,7 +1185,7 @@ msgstr "Loading messages…"
|
||||
msgid "Loading more…"
|
||||
msgstr "Loading more…"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:82
|
||||
#: src/pages/PlaylistDetail.tsx:83
|
||||
msgid "Loading playlist…"
|
||||
msgstr "Loading playlist…"
|
||||
|
||||
@@ -1548,9 +1555,9 @@ msgstr "Previous track"
|
||||
#: src/components/dump/DumpCard.tsx:144
|
||||
#: src/components/dump/JournalCard.tsx:146
|
||||
#: 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/pages/Dump.tsx:582
|
||||
#: src/pages/Dump.tsx:498
|
||||
msgid "private"
|
||||
msgstr "private"
|
||||
|
||||
@@ -1559,7 +1566,7 @@ msgid "Private"
|
||||
msgstr "Private"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:126
|
||||
#: src/components/playlist/PlaylistHeader.tsx:81
|
||||
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
@@ -1593,7 +1600,7 @@ msgstr "Registering…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Registration failed"
|
||||
|
||||
#: src/pages/Dump.tsx:634
|
||||
#: src/pages/Dump.tsx:567
|
||||
msgid "Related"
|
||||
msgstr "Related"
|
||||
|
||||
@@ -1680,7 +1687,7 @@ msgid "email.reset.title"
|
||||
msgstr "Reset your password"
|
||||
|
||||
#: src/components/chat/PendingItem.tsx:86
|
||||
#: src/pages/Dump.tsx:393
|
||||
#: src/pages/Dump.tsx:392
|
||||
#: src/pages/DumpEdit.tsx:227
|
||||
msgid "Retry"
|
||||
msgstr "Retry"
|
||||
@@ -1692,20 +1699,16 @@ msgstr "Role"
|
||||
#: src/components/category/CategoryManager.tsx:232
|
||||
#: src/components/chat/ChatEditBox.tsx:85
|
||||
#: src/components/comment/CommentNode.tsx:216
|
||||
#: src/components/form/InlineEdit.tsx:269
|
||||
#: 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
|
||||
msgid "Save"
|
||||
msgstr "Save"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:217
|
||||
#: src/components/form/InlineEdit.tsx:266
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:226
|
||||
#: 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
|
||||
msgid "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/layout/PageError.tsx:29
|
||||
#: src/contexts/ws/WSProvider.tsx:738
|
||||
#: src/pages/PlaylistDetail.tsx:155
|
||||
#: src/pages/PlaylistDetail.tsx:157
|
||||
msgid "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."
|
||||
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."
|
||||
msgstr "This dump was deleted, or made private."
|
||||
|
||||
@@ -2033,7 +2036,7 @@ msgstr "updated {ago}"
|
||||
msgid "email.digest.upload"
|
||||
msgstr "upload"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:79
|
||||
#: src/components/profile/ProfileHeader.tsx:80
|
||||
msgid "Upload failed"
|
||||
msgstr "Upload failed"
|
||||
|
||||
@@ -2136,12 +2139,14 @@ msgid "email.welcome.subject"
|
||||
msgstr "Welcome to {site}"
|
||||
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:705
|
||||
#: src/pages/Dump.tsx:514
|
||||
#: src/pages/Dump.tsx:521
|
||||
#: src/pages/DumpEdit.tsx:539
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "What makes it worth it?"
|
||||
|
||||
#: src/components/profile/DescriptionEditor.tsx:78
|
||||
#: src/components/profile/ProfileDescription.tsx:79
|
||||
#: src/components/profile/ProfileDescription.tsx:62
|
||||
#: src/components/profile/ProfileDescription.tsx:72
|
||||
msgid "Who am I?"
|
||||
msgstr "Who am I?"
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
103
locales/fr.po
103
locales/fr.po
@@ -206,14 +206,14 @@ msgstr "# Bienvenue sur {site} !\n"
|
||||
"Votre compte a bien été créé. Bienvenue à bord !"
|
||||
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:736
|
||||
#: src/pages/PlaylistDetail.tsx:98
|
||||
#: src/pages/PlaylistDetail.tsx:99
|
||||
#: src/pages/UserPublicProfile.tsx:129
|
||||
msgid "← Back"
|
||||
msgstr "← Retour"
|
||||
|
||||
#: src/pages/Dump.tsx:375
|
||||
#: src/pages/Dump.tsx:400
|
||||
#: src/pages/Dump.tsx:627
|
||||
#: src/pages/Dump.tsx:374
|
||||
#: src/pages/Dump.tsx:399
|
||||
#: src/pages/Dump.tsx:560
|
||||
#: src/pages/DumpEdit.tsx:234
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Retour à toutes les recos"
|
||||
@@ -226,7 +226,7 @@ msgstr "+ Inviter quelqu'un"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ Nouvelle collection"
|
||||
|
||||
#: src/pages/Dump.tsx:474
|
||||
#: src/pages/Dump.tsx:451
|
||||
msgid "+ Playlist"
|
||||
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>"
|
||||
|
||||
#. placeholder {0}: event.username
|
||||
#: src/components/social/CommunityFeed.tsx:144
|
||||
#: src/components/social/CommunityFeed.tsx:145
|
||||
msgid "<0>{0}</0> joined"
|
||||
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."
|
||||
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}"
|
||||
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>"
|
||||
msgstr "<0>Cette semaine :</0> <1>{figures}</1>"
|
||||
|
||||
#. 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>"
|
||||
msgstr "<0>En tête cette semaine :</0> <1>{0}</1>"
|
||||
|
||||
@@ -344,7 +344,7 @@ msgstr "une publication"
|
||||
msgid "Account"
|
||||
msgstr "Compte"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:144
|
||||
#: src/components/profile/ProfileHeader.tsx:151
|
||||
msgid "Account on <0>{host}</0>"
|
||||
msgstr "Compte sur <0>{host}</0>"
|
||||
|
||||
@@ -352,12 +352,17 @@ msgstr "Compte sur <0>{host}</0>"
|
||||
msgid "Add a comment…"
|
||||
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:116
|
||||
msgid "Add category"
|
||||
msgstr "Ajouter une catégorie"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:173
|
||||
#: src/components/profile/ProfileHeader.tsx:168
|
||||
msgid "Add email…"
|
||||
msgstr "Ajouter un e-mail…"
|
||||
|
||||
@@ -470,12 +475,10 @@ msgstr "Connexion impossible"
|
||||
#: src/components/comment/CommentForm.tsx:127
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:746
|
||||
#: src/components/form/FormActions.tsx:42
|
||||
#: src/components/form/InlineEdit.tsx:277
|
||||
#: 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/pages/Authorize.tsx:158
|
||||
#: src/pages/Dump.tsx:516
|
||||
#: src/pages/DumpEdit.tsx:561
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
@@ -501,7 +504,7 @@ msgstr "Catégories"
|
||||
msgid "Category"
|
||||
msgstr "Catégorie"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:102
|
||||
#: src/components/profile/ProfileHeader.tsx:109
|
||||
msgid "Change avatar"
|
||||
msgstr "Changer l'avatar"
|
||||
|
||||
@@ -721,7 +724,7 @@ msgstr "Échec de la suppression ({0})"
|
||||
#: src/components/playlist/PlaylistCard.tsx:172
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||
#: src/pages/PlaylistDetail.tsx:172
|
||||
#: src/pages/PlaylistDetail.tsx:174
|
||||
msgid "Delete playlist"
|
||||
msgstr "Supprimer la collection"
|
||||
|
||||
@@ -738,7 +741,7 @@ msgid "Delete this message?"
|
||||
msgstr "Supprimer ce message ?"
|
||||
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||
#: src/pages/PlaylistDetail.tsx:171
|
||||
#: src/pages/PlaylistDetail.tsx:173
|
||||
msgid "Delete this playlist? This cannot be undone."
|
||||
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/PendingItem.tsx:93
|
||||
#: src/components/comment/CommentNode.tsx:256
|
||||
#: src/components/playlist/PlaylistHeader.tsx:64
|
||||
#: src/pages/Dump.tsx:623
|
||||
#: src/components/playlist/PlaylistHeader.tsx:104
|
||||
#: src/pages/Dump.tsx:556
|
||||
msgid "Edit"
|
||||
msgstr "Modifier"
|
||||
|
||||
@@ -837,16 +840,22 @@ msgstr "Modifier"
|
||||
msgid "Edit {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"
|
||||
msgstr "Modifier la description"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:171
|
||||
#: src/components/profile/ProfileHeader.tsx:161
|
||||
msgid "Edit email"
|
||||
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/pages/Dump.tsx:533
|
||||
#: src/components/playlist/PlaylistHeader.tsx:84
|
||||
#: src/pages/Dump.tsx:459
|
||||
msgid "Edit title"
|
||||
msgstr "Modifier le titre"
|
||||
|
||||
@@ -859,8 +868,8 @@ msgstr "modifié"
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/chat/ChatMessageItem.tsx:132
|
||||
#: src/components/comment/CommentNode.tsx:205
|
||||
#: src/components/playlist/PlaylistHeader.tsx:99
|
||||
#: src/pages/Dump.tsx:576
|
||||
#: src/components/playlist/PlaylistHeader.tsx:158
|
||||
#: src/pages/Dump.tsx:492
|
||||
msgid "edited {0}"
|
||||
msgstr "modifié {0}"
|
||||
|
||||
@@ -869,8 +878,8 @@ msgstr "modifié {0}"
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/chat/ChatMessageItem.tsx:128
|
||||
#: src/components/comment/CommentNode.tsx:203
|
||||
#: src/components/playlist/PlaylistHeader.tsx:97
|
||||
#: src/pages/Dump.tsx:574
|
||||
#: src/components/playlist/PlaylistHeader.tsx:156
|
||||
#: src/pages/Dump.tsx:490
|
||||
msgid "Edited {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/PeerManager.tsx:158
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:207
|
||||
#: src/components/profile/DescriptionEditor.tsx:99
|
||||
#: src/components/profile/EmailEditor.tsx:86
|
||||
msgid "Failed to save"
|
||||
msgstr "Enregistrement échoué"
|
||||
|
||||
@@ -949,7 +956,7 @@ msgstr "Échec de l’enregistrement de la catégorie"
|
||||
msgid "Failed to save edit"
|
||||
msgstr "Impossible d'enregistrer la modification"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:182
|
||||
#: src/components/profile/ProfileHeader.tsx:174
|
||||
msgid "Failed to update 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"
|
||||
msgstr "image"
|
||||
|
||||
#: src/pages/Dump.tsx:643
|
||||
#: src/pages/Dump.tsx:576
|
||||
msgid "In collections"
|
||||
msgstr "Dans les collections"
|
||||
|
||||
@@ -1095,7 +1102,7 @@ msgstr "Lien invalide"
|
||||
msgid "Invite link"
|
||||
msgstr "Lien d'invitation"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:126
|
||||
#: src/components/profile/ProfileHeader.tsx:133
|
||||
msgid "invited by"
|
||||
msgstr "invité par"
|
||||
|
||||
@@ -1165,7 +1172,7 @@ msgstr "Charger plus"
|
||||
msgid "Load older messages"
|
||||
msgstr "Charger les messages plus anciens"
|
||||
|
||||
#: src/pages/Dump.tsx:359
|
||||
#: src/pages/Dump.tsx:358
|
||||
#: src/pages/DumpEdit.tsx:210
|
||||
msgid "Loading dump…"
|
||||
msgstr "Chargement de la reco…"
|
||||
@@ -1178,7 +1185,7 @@ msgstr "Chargement des messages…"
|
||||
msgid "Loading more…"
|
||||
msgstr "Chargement…"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:82
|
||||
#: src/pages/PlaylistDetail.tsx:83
|
||||
msgid "Loading playlist…"
|
||||
msgstr "Chargement de la collection…"
|
||||
|
||||
@@ -1548,9 +1555,9 @@ msgstr "Piste précédente"
|
||||
#: src/components/dump/DumpCard.tsx:144
|
||||
#: src/components/dump/JournalCard.tsx:146
|
||||
#: 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/pages/Dump.tsx:582
|
||||
#: src/pages/Dump.tsx:498
|
||||
msgid "private"
|
||||
msgstr "privé"
|
||||
|
||||
@@ -1559,7 +1566,7 @@ msgid "Private"
|
||||
msgstr "Privé"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:126
|
||||
#: src/components/playlist/PlaylistHeader.tsx:81
|
||||
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
@@ -1593,7 +1600,7 @@ msgstr "Inscription…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Inscription échouée"
|
||||
|
||||
#: src/pages/Dump.tsx:634
|
||||
#: src/pages/Dump.tsx:567
|
||||
msgid "Related"
|
||||
msgstr "Connexe"
|
||||
|
||||
@@ -1680,7 +1687,7 @@ msgid "email.reset.title"
|
||||
msgstr "Réinitialiser votre mot de passe"
|
||||
|
||||
#: src/components/chat/PendingItem.tsx:86
|
||||
#: src/pages/Dump.tsx:393
|
||||
#: src/pages/Dump.tsx:392
|
||||
#: src/pages/DumpEdit.tsx:227
|
||||
msgid "Retry"
|
||||
msgstr "Réessayer"
|
||||
@@ -1692,20 +1699,16 @@ msgstr "Rôle"
|
||||
#: src/components/category/CategoryManager.tsx:232
|
||||
#: src/components/chat/ChatEditBox.tsx:85
|
||||
#: src/components/comment/CommentNode.tsx:216
|
||||
#: src/components/form/InlineEdit.tsx:269
|
||||
#: 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
|
||||
msgid "Save"
|
||||
msgstr "Enregistrer"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:217
|
||||
#: src/components/form/InlineEdit.tsx:266
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:226
|
||||
#: 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
|
||||
msgid "Saving…"
|
||||
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/layout/PageError.tsx:29
|
||||
#: src/contexts/ws/WSProvider.tsx:738
|
||||
#: src/pages/PlaylistDetail.tsx:155
|
||||
#: src/pages/PlaylistDetail.tsx:157
|
||||
msgid "Something went wrong"
|
||||
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."
|
||||
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."
|
||||
msgstr "Cette reco a été supprimée, ou rendue privée."
|
||||
|
||||
@@ -2033,7 +2036,7 @@ msgstr "mise à jour {ago}"
|
||||
msgid "email.digest.upload"
|
||||
msgstr "fichier"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:79
|
||||
#: src/components/profile/ProfileHeader.tsx:80
|
||||
msgid "Upload failed"
|
||||
msgstr "Envoi échoué"
|
||||
|
||||
@@ -2136,12 +2139,14 @@ msgid "email.welcome.subject"
|
||||
msgstr "Bienvenue sur {site}"
|
||||
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:705
|
||||
#: src/pages/Dump.tsx:514
|
||||
#: src/pages/Dump.tsx:521
|
||||
#: src/pages/DumpEdit.tsx:539
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "Pourquoi on en voudrait ?"
|
||||
|
||||
#: src/components/profile/DescriptionEditor.tsx:78
|
||||
#: src/components/profile/ProfileDescription.tsx:79
|
||||
#: src/components/profile/ProfileDescription.tsx:62
|
||||
#: src/components/profile/ProfileDescription.tsx:72
|
||||
msgid "Who am I?"
|
||||
msgstr "Qui suis-je ?"
|
||||
|
||||
|
||||
306
src/App.css
306
src/App.css
@@ -223,67 +223,151 @@
|
||||
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;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
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;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.dump-title-view--editable {
|
||||
cursor: pointer;
|
||||
/* Room on the right for the ✎, which sits in the corner. */
|
||||
.inline-edit--block .inline-edit-view {
|
||||
display: block;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.dump-title-view--editable:hover {
|
||||
background: var(--color-surface);
|
||||
.inline-edit-view:hover::before {
|
||||
background: var(--inline-edit-plate);
|
||||
}
|
||||
|
||||
.dump-title-edit-btn {
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1;
|
||||
.inline-edit-mark {
|
||||
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);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-fast);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dump-title-view--editable:hover .dump-title-edit-btn,
|
||||
.dump-title-view--editable:focus-visible .dump-title-edit-btn {
|
||||
.inline-edit--block .inline-edit-mark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.inline-edit-view:hover .inline-edit-mark,
|
||||
.inline-edit-mark:focus-visible {
|
||||
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;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Shared chrome for inline-edit inputs (the editors that live outside `.form`).
|
||||
Each rule below adds only its own size / weight / width deltas. */
|
||||
.dump-title-input,
|
||||
.profile-email-input,
|
||||
.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);
|
||||
.inline-edit-error {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* A name and its "Rename" button, the hint and errors below. */
|
||||
@@ -304,28 +388,18 @@
|
||||
flex: 1 1 10rem;
|
||||
min-width: 0;
|
||||
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);
|
||||
outline: none;
|
||||
transition: border-color var(--dur-slow);
|
||||
}
|
||||
|
||||
.dump-title-input {
|
||||
flex: 1;
|
||||
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);
|
||||
.username-field-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.profile-description {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-description-view {
|
||||
position: relative;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.profile-description-view--editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-description-view--editable:hover {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.profile-description-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
@@ -2381,80 +2441,16 @@ body.has-player .chat-fab {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.profile-description-empty {
|
||||
color: var(--color-muted);
|
||||
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;
|
||||
.profile-email {
|
||||
margin: 0.1rem 0 0.4rem;
|
||||
font-size: var(--text-xs);
|
||||
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 {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.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);
|
||||
/* An address is typed a size up from the line it shows on. */
|
||||
.profile-email .inline-edit-input {
|
||||
max-width: 22rem;
|
||||
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 {
|
||||
@@ -5125,7 +5121,10 @@ body.has-player .chat-fab {
|
||||
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-height: 2.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
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
|
||||
ran under them, and in a row below they would knock the Send button out of
|
||||
alignment. */
|
||||
.chat-composer .chat-input {
|
||||
min-height: var(--chat-composer-height);
|
||||
}
|
||||
|
||||
.chat-composer .text-editor-footer {
|
||||
position: absolute;
|
||||
left: 0.25rem;
|
||||
@@ -5168,9 +5171,9 @@ body.has-player .chat-fab {
|
||||
|
||||
.chat-send-btn {
|
||||
flex-shrink: 0;
|
||||
/* Match the input's resting height so the two line up exactly (both edges),
|
||||
while the input is free to grow taller as you type. */
|
||||
height: 2.4rem;
|
||||
/* The input's resting height, so that the two line up at both edges, while
|
||||
the input is free to grow taller as you type. */
|
||||
height: var(--chat-composer-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -5518,6 +5521,23 @@ body.has-player .chat-fab {
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
|
||||
@@ -850,7 +850,7 @@ export function ChatModal(
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
placeholder={t`Type a message…`}
|
||||
rows={2}
|
||||
rows={1}
|
||||
autoResize
|
||||
maxLength={VALIDATION.CHAT_MESSAGE_MAX}
|
||||
countFrom={COUNT_FROM}
|
||||
|
||||
299
src/components/form/InlineEdit.tsx
Normal file
299
src/components/form/InlineEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,13 @@
|
||||
* @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 { OnlineUser } from "../../../shared/ws.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
@@ -24,11 +30,16 @@ function onMotionChange(callback: () => void): () => void {
|
||||
return () => query.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
// When the crawl began its current loop. It is kept out of the component,
|
||||
// which every page mounts anew with its header: the next page's marquee
|
||||
// carries on from where the last one's had got to, rather than from the
|
||||
// start.
|
||||
let loopStart: number | undefined;
|
||||
// Where the crawl had got to, and what its loop showed, when the marquee was
|
||||
// last on screen. They are kept out of the component, which every page mounts
|
||||
// anew with its header: the next page's marquee carries on from that point of
|
||||
// that loop, rather than from the start. A crawl that was running has gone on
|
||||
// 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. */
|
||||
interface Loop {
|
||||
@@ -61,8 +72,12 @@ function FeedMarquee(): React.JSX.Element {
|
||||
onMotionChange,
|
||||
() => 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 });
|
||||
useEffect(() => {
|
||||
lastLoop = loop;
|
||||
}, [loop]);
|
||||
const shown = still ? feed && { feed, online } : loop;
|
||||
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
@@ -71,16 +86,32 @@ function FeedMarquee(): React.JSX.Element {
|
||||
const length = track?.firstElementChild?.getBoundingClientRect().width;
|
||||
if (!track || !length) return;
|
||||
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.
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="site-marquee"
|
||||
// A focused link stops the crawl, and the browser scrolls the line to
|
||||
// show it: the line goes back to its start once the focus has left.
|
||||
// A link focused from the keyboard stops the crawl, and the browser
|
||||
// scrolls the line to show it: the line goes back to its start once the
|
||||
// focus has left.
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
e.currentTarget.scrollLeft = 0;
|
||||
@@ -95,7 +126,6 @@ function FeedMarquee(): React.JSX.Element {
|
||||
onAnimationIteration={(e) => {
|
||||
// Not a child's animation ending a round of its own.
|
||||
if (e.target !== e.currentTarget || !feed || !loop) return;
|
||||
loopStart = performance.now();
|
||||
setLoop({ feed, online, turn: loop.turn + 1 });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -8,12 +8,22 @@
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/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 { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
import { CategoryChips } from "../category/CategoryChips.tsx";
|
||||
@@ -22,18 +32,38 @@ import { userPath } from "../../../shared/paths.ts";
|
||||
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
|
||||
export interface PlaylistHeaderProps {
|
||||
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;
|
||||
/**
|
||||
* 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
|
||||
* 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(
|
||||
{ playlist, onEdit }: PlaylistHeaderProps,
|
||||
{ playlist, onEdit, onSaved }: PlaylistHeaderProps,
|
||||
): 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 (
|
||||
<>
|
||||
{playlist.imageMime && (
|
||||
@@ -48,7 +78,17 @@ export function PlaylistHeader(
|
||||
)}
|
||||
<div className="playlist-detail-content">
|
||||
<div className="playlist-detail-title-row">
|
||||
<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 && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
@@ -66,10 +106,29 @@ export function PlaylistHeader(
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playlist.description && (
|
||||
{(playlist.description || onSaved) && (
|
||||
<InlineEdit
|
||||
value={playlist.description ?? ""}
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,16 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/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 { DescriptionEditor } from "./DescriptionEditor.tsx";
|
||||
|
||||
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
|
||||
export interface ProfileDescriptionProps {
|
||||
@@ -22,51 +26,40 @@ export interface ProfileDescriptionProps {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* or Enter swaps in {@link DescriptionEditor}.
|
||||
* their own profile, users get a "Who am I?" placeholder instead, and edit it
|
||||
* in place, saved to `PATCH /api/me`. Other browsers see the change through
|
||||
* the socket's `user_updated` event.
|
||||
*/
|
||||
export function ProfileDescription(
|
||||
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps,
|
||||
): React.JSX.Element | null {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const ref = useHeightTransition<HTMLDivElement>(editing);
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
if (!description && !isOwnProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="profile-description" ref={ref}>
|
||||
{editing
|
||||
? (
|
||||
<DescriptionEditor
|
||||
initialDescription={description ?? ""}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSaved={(saved) => {
|
||||
onSaved(saved);
|
||||
setEditing(false);
|
||||
<div className="profile-description">
|
||||
<InlineEdit
|
||||
value={description ?? ""}
|
||||
editable={isOwnProfile}
|
||||
label={t`Edit description`}
|
||||
onSave={async (saved) => {
|
||||
// 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: saved } satisfies UpdateUserRequest,
|
||||
),
|
||||
}),
|
||||
);
|
||||
onSaved(saved || undefined);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className={`profile-description-view${
|
||||
isOwnProfile ? " profile-description-view--editable" : ""
|
||||
}`}
|
||||
onClick={isOwnProfile ? () => setEditing(true) : undefined}
|
||||
// Focusable rather than a button, so the Markdown inside keeps its
|
||||
// own structure and links.
|
||||
tabIndex={isOwnProfile ? 0 : undefined}
|
||||
title={isOwnProfile ? t`Edit description` : undefined}
|
||||
onKeyDown={isOwnProfile
|
||||
? (e) => {
|
||||
if (
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
e.target === e.currentTarget
|
||||
) {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
kind="markdown"
|
||||
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||
placeholder={t`Who am I?`}
|
||||
>
|
||||
{description
|
||||
? (
|
||||
@@ -75,17 +68,11 @@ export function ProfileDescription(
|
||||
</Markdown>
|
||||
)
|
||||
: (
|
||||
<div className="profile-description-empty">
|
||||
<p className="inline-edit-empty">
|
||||
<Trans>Who am I?</Trans>
|
||||
</div>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<span className="profile-description-edit-btn" aria-hidden>
|
||||
✎
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</InlineEdit>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { PublicUser, SelfUser } from "../../../shared/user.ts";
|
||||
import { parseAPIResponse } from "../../utils/shared/api.ts";
|
||||
import type {
|
||||
PublicUser,
|
||||
SelfUser,
|
||||
UpdateUserRequest,
|
||||
} from "../../../shared/user.ts";
|
||||
import { expectOk, parseAPIResponse } from "../../utils/shared/api.ts";
|
||||
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
import { FollowUserButton } from "../social/FollowButton.tsx";
|
||||
import { EmailEditor } from "./EmailEditor.tsx";
|
||||
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
|
||||
import { RoleChip } from "./RoleSwitcher.tsx";
|
||||
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
|
||||
* 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
|
||||
* 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
|
||||
* header's avatar menu.
|
||||
*/
|
||||
@@ -49,8 +52,6 @@ export function ProfileHeader(
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [emailEditing, setEmailEditing] = useState(false);
|
||||
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
|
||||
const { name, host } = splitHandle(user.username);
|
||||
// A user from another instance changes their avatar and email there.
|
||||
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 } });
|
||||
setEmailEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -112,7 +119,7 @@ export function ProfileHeader(
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-info" ref={infoRef}>
|
||||
<div className="profile-info">
|
||||
<div className="profile-username-row">
|
||||
<h1 className="profile-username">
|
||||
{name}
|
||||
@@ -148,34 +155,19 @@ export function ProfileHeader(
|
||||
</p>
|
||||
)}
|
||||
{editable && (
|
||||
emailEditing
|
||||
? (
|
||||
<EmailEditor
|
||||
initialEmail={me?.email ?? ""}
|
||||
onCancel={() => setEmailEditing(false)}
|
||||
onSaved={handleEmailSaved}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<p
|
||||
className="profile-email-display"
|
||||
onClick={() => setEmailEditing(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setEmailEditing(true);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
title={t`Edit email`}
|
||||
<InlineEdit
|
||||
value={me?.email ?? ""}
|
||||
editable
|
||||
label={t`Edit email`}
|
||||
onSave={handleEmailSave}
|
||||
kind="email"
|
||||
required
|
||||
className="profile-email"
|
||||
>
|
||||
<span className="profile-email-text">
|
||||
{me?.email ?? t`Add email…`}
|
||||
<span className="profile-description-edit-btn" aria-hidden>
|
||||
✎
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
</InlineEdit>
|
||||
)}
|
||||
{avatarError && (
|
||||
<ErrorCard
|
||||
|
||||
@@ -33,12 +33,15 @@ import {
|
||||
deserializePlaylist,
|
||||
deserializePublicUser,
|
||||
} 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 { useDocumentTitle } from "../hooks/shared/useDocumentTitle.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 { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
||||
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 { CommentThread } from "../components/comment/CommentThread.tsx";
|
||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||
import { InlineEdit } from "../components/form/InlineEdit.tsx";
|
||||
import { userPath } from "../../shared/paths.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
|
||||
* 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
|
||||
* events keep them current; a reconnection fetches them and the dump again. A dump deleted, or made private by its poster, while someone else
|
||||
* views it gives way to a notice. The poster and moderators can edit the
|
||||
* title in place, and get a link to `DumpEdit`.
|
||||
* events keep them current; a reconnection fetches them and the dump again.
|
||||
* A dump deleted, or made private by its poster, while someone else views it
|
||||
* 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 {
|
||||
const { handle, slug } = useParams();
|
||||
@@ -129,12 +134,6 @@ export function Dump(): React.JSX.Element {
|
||||
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
|
||||
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 {
|
||||
voteCounts,
|
||||
@@ -409,34 +408,17 @@ export function Dump(): React.JSX.Element {
|
||||
const canEdit = !!user &&
|
||||
(dump.userId === user.id || can(user, "dump:moderate"));
|
||||
|
||||
const startTitleEdit = () => {
|
||||
setTitleDraft(dump.title);
|
||||
setTitleError(null);
|
||||
setTitleEditing(true);
|
||||
};
|
||||
|
||||
const handleTitleSave = async () => {
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
|
||||
|
||||
setTitleSaving(true);
|
||||
setTitleError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
||||
// Saves what the title or the note was changed to in place. The page stays
|
||||
// mounted across a slug change, and would keep showing the dump it had.
|
||||
const saveDump = async (changes: UpdateDumpRequest) => {
|
||||
const updatedDump = deserializeDump(
|
||||
await expectOk<Wire<Dump>>(
|
||||
await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(
|
||||
{ title: trimmed } satisfies UpdateDumpRequest,
|
||||
body: JSON.stringify(changes),
|
||||
}),
|
||||
),
|
||||
});
|
||||
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), {
|
||||
@@ -444,11 +426,6 @@ export function Dump(): React.JSX.Element {
|
||||
state: { dump: updatedDump },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setTitleError(friendlyFetchError(err));
|
||||
} finally {
|
||||
setTitleSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -475,79 +452,18 @@ export function Dump(): React.JSX.Element {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="dump-header-right" ref={headerRightRef}>
|
||||
{titleEditing
|
||||
? (
|
||||
<div className="dump-title-editor animate-in">
|
||||
<input
|
||||
type="text"
|
||||
className="dump-title-input"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleTitleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
setTitleEditing(false);
|
||||
}
|
||||
}}
|
||||
<div className="dump-header-right">
|
||||
<InlineEdit
|
||||
value={dump.title}
|
||||
editable={canEdit}
|
||||
label={t`Edit title`}
|
||||
onSave={(title) => saveDump({ title })}
|
||||
required
|
||||
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}
|
||||
fieldClassName="dump-title"
|
||||
>
|
||||
<h1 className="dump-title">{dump.title}</h1>
|
||||
{canEdit && (
|
||||
<span className="dump-title-edit-btn" aria-hidden>
|
||||
✎
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</InlineEdit>
|
||||
<div className="dump-op">
|
||||
<Avatar
|
||||
userId={dump.userId}
|
||||
@@ -587,8 +503,25 @@ export function Dump(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dump.comment && (
|
||||
<Markdown className="dump-comment">{dump.comment}</Markdown>
|
||||
{(dump.comment || canEdit) && (
|
||||
<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>
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ export interface PlaylistDetailProps {
|
||||
* 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`,
|
||||
* 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
|
||||
* place, so opening the editor keeps the playlist loaded. Anyone else there
|
||||
* gets the page, or is asked to sign in first.
|
||||
@@ -143,9 +144,10 @@ export function PlaylistDetail(
|
||||
<div className="playlist-detail-header-top">
|
||||
<PlaylistHeader
|
||||
playlist={playlist}
|
||||
onEdit={canEdit
|
||||
? () => navigate(playlistUrl(playlist, "edit"))
|
||||
: undefined}
|
||||
{...(canEdit && {
|
||||
onEdit: () => navigate(playlistUrl(playlist, "edit")),
|
||||
onSaved: applyDetails,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
88
src/test/components/form/InlineEdit.test.tsx
Normal file
88
src/test/components/form/InlineEdit.test.tsx
Normal 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!");
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,35 @@ describe("Dump page", () => {
|
||||
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 () => {
|
||||
dumpRoutes();
|
||||
const { user } = openDump(other);
|
||||
@@ -157,7 +186,7 @@ describe("Dump page", () => {
|
||||
dumpRoutes();
|
||||
const { user } = openDump(owner);
|
||||
await screen.findByRole("heading", { name: "A find" });
|
||||
screen.getByTitle("Edit title").focus();
|
||||
screen.getByRole("button", { name: "Edit title" }).focus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -100,6 +100,7 @@ describe("PlaylistDetail", () => {
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit title" })).toBeNull();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { user } = open({ url: "/@owner/playlists/mix/edit" });
|
||||
expect(await screen.findByRole("textbox", { name: "Description" }))
|
||||
|
||||
@@ -66,7 +66,8 @@ describe("UserPublicProfile", () => {
|
||||
`${API_URL}/api/users/@alice%40home.example/home`,
|
||||
);
|
||||
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(calls("GET /api/users/:u")[0].path).toBe(
|
||||
"/api/users/@alice%40home.example",
|
||||
@@ -451,10 +452,8 @@ describe("UserPublicProfile", () => {
|
||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
const view = (await screen.findByText("dump")).closest(
|
||||
"[title='Edit description']",
|
||||
) as HTMLElement;
|
||||
view.focus();
|
||||
await screen.findByText("dump");
|
||||
screen.getByRole("button", { name: "Edit description" }).focus();
|
||||
await user.keyboard("{Enter}");
|
||||
const box = screen.getByPlaceholderText("Who am I?");
|
||||
await user.clear(box);
|
||||
@@ -469,14 +468,11 @@ describe("UserPublicProfile", () => {
|
||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
||||
);
|
||||
await user.click(await screen.findByText("ann@x.test"));
|
||||
const input = screen.getByDisplayValue("ann@x.test");
|
||||
await user.clear(input);
|
||||
await user.type(input, "new@x.test{Enter}");
|
||||
expect(await screen.findByRole("button", { name: /new@x\.test/ }))
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByText("new@x.test")).toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
|
||||
.toBe("new@x.test");
|
||||
});
|
||||
@@ -484,9 +480,7 @@ describe("UserPublicProfile", () => {
|
||||
test("a refused email says why", async () => {
|
||||
route("PATCH /api/me", () => fail("Email already in use", 409));
|
||||
const { user } = open({ session: annSession });
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
||||
);
|
||||
await user.click(await screen.findByText("ann@x.test"));
|
||||
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Email already in use",
|
||||
|
||||
@@ -172,7 +172,8 @@
|
||||
[data-style="brutalist"] .user-menu-trigger,
|
||||
[data-style="brutalist"] .user-menu-item,
|
||||
[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;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@@ -184,7 +185,9 @@
|
||||
[data-style="brutalist"] .form-cancel:hover:not(:disabled),
|
||||
[data-style="brutalist"] .form-cancel:active,
|
||||
[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;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
|
||||
/* Pressed: the bevel turns inside out and the label moves down and right a
|
||||
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-secondary:active:not(:disabled, [aria-disabled="true"]),
|
||||
[data-style="geocities"] .btn-border:active:not(:disabled, [aria-disabled="true"]),
|
||||
@@ -653,13 +653,16 @@
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* The crawl stops under the pointer, and for a link that has the focus: the
|
||||
line then stands at its start, and the browser scrolls it to that link. */
|
||||
/* The crawl stops under the pointer, and for a link the keyboard has
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
.form-field,
|
||||
.profile-role-chip,
|
||||
.profile-invited-by,
|
||||
.profile-email-display,
|
||||
.profile-email,
|
||||
.profile-section-header,
|
||||
.profile-appearance-row,
|
||||
.playlist-badge,
|
||||
|
||||
Reference in New Issue
Block a user