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!"
|
"Your account has been created successfully. Welcome aboard!"
|
||||||
|
|
||||||
#: src/components/dump/create/DumpCreateModal.tsx:736
|
#: src/components/dump/create/DumpCreateModal.tsx:736
|
||||||
#: src/pages/PlaylistDetail.tsx:98
|
#: src/pages/PlaylistDetail.tsx:99
|
||||||
#: src/pages/UserPublicProfile.tsx:129
|
#: src/pages/UserPublicProfile.tsx:129
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Back"
|
msgstr "← Back"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:375
|
#: src/pages/Dump.tsx:374
|
||||||
#: src/pages/Dump.tsx:400
|
#: src/pages/Dump.tsx:399
|
||||||
#: src/pages/Dump.tsx:627
|
#: src/pages/Dump.tsx:560
|
||||||
#: src/pages/DumpEdit.tsx:234
|
#: src/pages/DumpEdit.tsx:234
|
||||||
msgid "← Back to all dumps"
|
msgid "← Back to all dumps"
|
||||||
msgstr "← Back to all dumps"
|
msgstr "← Back to all dumps"
|
||||||
@@ -226,7 +226,7 @@ msgstr "+ Invite someone"
|
|||||||
msgid "+ New playlist"
|
msgid "+ New playlist"
|
||||||
msgstr "+ New playlist"
|
msgstr "+ New playlist"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:474
|
#: src/pages/Dump.tsx:451
|
||||||
msgid "+ Playlist"
|
msgid "+ Playlist"
|
||||||
msgstr "+ Playlist"
|
msgstr "+ Playlist"
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
|||||||
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
|
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: event.username
|
#. placeholder {0}: event.username
|
||||||
#: src/components/social/CommunityFeed.tsx:144
|
#: src/components/social/CommunityFeed.tsx:145
|
||||||
msgid "<0>{0}</0> joined"
|
msgid "<0>{0}</0> joined"
|
||||||
msgstr "<0>{0}</0> joined"
|
msgstr "<0>{0}</0> joined"
|
||||||
|
|
||||||
@@ -311,16 +311,16 @@ msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
|||||||
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
|
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
|
||||||
msgstr "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
|
msgstr "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
|
||||||
|
|
||||||
#: src/components/social/CommunityFeed.tsx:155
|
#: src/components/social/CommunityFeed.tsx:159
|
||||||
msgid "<0>Online now:</0> {names}"
|
msgid "<0>Online now:</0> {names}"
|
||||||
msgstr "<0>Online now:</0> {names}"
|
msgstr "<0>Online now:</0> {names}"
|
||||||
|
|
||||||
#: src/components/social/CommunityFeed.tsx:162
|
#: src/components/social/CommunityFeed.tsx:166
|
||||||
msgid "<0>This week:</0> <1>{figures}</1>"
|
msgid "<0>This week:</0> <1>{figures}</1>"
|
||||||
msgstr "<0>This week:</0> <1>{figures}</1>"
|
msgstr "<0>This week:</0> <1>{figures}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: top.title
|
#. placeholder {0}: top.title
|
||||||
#: src/components/social/CommunityFeed.tsx:170
|
#: src/components/social/CommunityFeed.tsx:174
|
||||||
msgid "<0>Top this week:</0> <1>{0}</1>"
|
msgid "<0>Top this week:</0> <1>{0}</1>"
|
||||||
msgstr "<0>Top this week:</0> <1>{0}</1>"
|
msgstr "<0>Top this week:</0> <1>{0}</1>"
|
||||||
|
|
||||||
@@ -344,7 +344,7 @@ msgstr "a post"
|
|||||||
msgid "Account"
|
msgid "Account"
|
||||||
msgstr "Account"
|
msgstr "Account"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:144
|
#: src/components/profile/ProfileHeader.tsx:151
|
||||||
msgid "Account on <0>{host}</0>"
|
msgid "Account on <0>{host}</0>"
|
||||||
msgstr "Account on <0>{host}</0>"
|
msgstr "Account on <0>{host}</0>"
|
||||||
|
|
||||||
@@ -352,12 +352,17 @@ msgstr "Account on <0>{host}</0>"
|
|||||||
msgid "Add a comment…"
|
msgid "Add a comment…"
|
||||||
msgstr "Add a comment…"
|
msgstr "Add a comment…"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistHeader.tsx:117
|
||||||
|
#: src/components/playlist/PlaylistHeader.tsx:128
|
||||||
|
msgid "Add a description…"
|
||||||
|
msgstr "Add a description…"
|
||||||
|
|
||||||
#: src/components/category/CategoryManager.tsx:115
|
#: src/components/category/CategoryManager.tsx:115
|
||||||
#: src/components/category/CategoryManager.tsx:116
|
#: src/components/category/CategoryManager.tsx:116
|
||||||
msgid "Add category"
|
msgid "Add category"
|
||||||
msgstr "Add category"
|
msgstr "Add category"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:173
|
#: src/components/profile/ProfileHeader.tsx:168
|
||||||
msgid "Add email…"
|
msgid "Add email…"
|
||||||
msgstr "Add email…"
|
msgstr "Add email…"
|
||||||
|
|
||||||
@@ -470,12 +475,10 @@ msgstr "Can't sign in there"
|
|||||||
#: src/components/comment/CommentForm.tsx:127
|
#: src/components/comment/CommentForm.tsx:127
|
||||||
#: src/components/dump/create/DumpCreateModal.tsx:746
|
#: src/components/dump/create/DumpCreateModal.tsx:746
|
||||||
#: src/components/form/FormActions.tsx:42
|
#: src/components/form/FormActions.tsx:42
|
||||||
|
#: src/components/form/InlineEdit.tsx:277
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:222
|
#: src/components/playlist/PlaylistEditForm.tsx:222
|
||||||
#: src/components/profile/DescriptionEditor.tsx:97
|
|
||||||
#: src/components/profile/EmailEditor.tsx:83
|
|
||||||
#: src/components/shared/ConfirmModal.tsx:63
|
#: src/components/shared/ConfirmModal.tsx:63
|
||||||
#: src/pages/Authorize.tsx:158
|
#: src/pages/Authorize.tsx:158
|
||||||
#: src/pages/Dump.tsx:516
|
|
||||||
#: src/pages/DumpEdit.tsx:561
|
#: src/pages/DumpEdit.tsx:561
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
@@ -501,7 +504,7 @@ msgstr "Categories"
|
|||||||
msgid "Category"
|
msgid "Category"
|
||||||
msgstr "Category"
|
msgstr "Category"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:102
|
#: src/components/profile/ProfileHeader.tsx:109
|
||||||
msgid "Change avatar"
|
msgid "Change avatar"
|
||||||
msgstr "Change avatar"
|
msgstr "Change avatar"
|
||||||
|
|
||||||
@@ -721,7 +724,7 @@ msgstr "Delete failed ({0})"
|
|||||||
#: src/components/playlist/PlaylistCard.tsx:172
|
#: src/components/playlist/PlaylistCard.tsx:172
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||||
#: src/pages/PlaylistDetail.tsx:172
|
#: src/pages/PlaylistDetail.tsx:174
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Delete playlist"
|
msgstr "Delete playlist"
|
||||||
|
|
||||||
@@ -738,7 +741,7 @@ msgid "Delete this message?"
|
|||||||
msgstr "Delete this message?"
|
msgstr "Delete this message?"
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||||
#: src/pages/PlaylistDetail.tsx:171
|
#: src/pages/PlaylistDetail.tsx:173
|
||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Delete this playlist? This cannot be undone."
|
msgstr "Delete this playlist? This cannot be undone."
|
||||||
|
|
||||||
@@ -827,8 +830,8 @@ msgstr "Earlier"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:108
|
#: src/components/chat/ChatMessageItem.tsx:108
|
||||||
#: src/components/chat/PendingItem.tsx:93
|
#: src/components/chat/PendingItem.tsx:93
|
||||||
#: src/components/comment/CommentNode.tsx:256
|
#: src/components/comment/CommentNode.tsx:256
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:64
|
#: src/components/playlist/PlaylistHeader.tsx:104
|
||||||
#: src/pages/Dump.tsx:623
|
#: src/pages/Dump.tsx:556
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Edit"
|
msgstr "Edit"
|
||||||
|
|
||||||
@@ -837,16 +840,22 @@ msgstr "Edit"
|
|||||||
msgid "Edit {0}"
|
msgid "Edit {0}"
|
||||||
msgstr "Edit {0}"
|
msgstr "Edit {0}"
|
||||||
|
|
||||||
#: src/components/profile/ProfileDescription.tsx:58
|
#: src/components/playlist/PlaylistHeader.tsx:113
|
||||||
|
#: src/components/profile/ProfileDescription.tsx:45
|
||||||
msgid "Edit description"
|
msgid "Edit description"
|
||||||
msgstr "Edit description"
|
msgstr "Edit description"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:171
|
#: src/components/profile/ProfileHeader.tsx:161
|
||||||
msgid "Edit email"
|
msgid "Edit email"
|
||||||
msgstr "Edit email"
|
msgstr "Edit email"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:510
|
||||||
|
msgid "Edit note"
|
||||||
|
msgstr "Edit note"
|
||||||
|
|
||||||
#: src/components/dump/create/TitleField.tsx:69
|
#: src/components/dump/create/TitleField.tsx:69
|
||||||
#: src/pages/Dump.tsx:533
|
#: src/components/playlist/PlaylistHeader.tsx:84
|
||||||
|
#: src/pages/Dump.tsx:459
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Edit title"
|
msgstr "Edit title"
|
||||||
|
|
||||||
@@ -859,8 +868,8 @@ msgstr "edited"
|
|||||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||||
#: src/components/chat/ChatMessageItem.tsx:132
|
#: src/components/chat/ChatMessageItem.tsx:132
|
||||||
#: src/components/comment/CommentNode.tsx:205
|
#: src/components/comment/CommentNode.tsx:205
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:99
|
#: src/components/playlist/PlaylistHeader.tsx:158
|
||||||
#: src/pages/Dump.tsx:576
|
#: src/pages/Dump.tsx:492
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
msgstr "edited {0}"
|
msgstr "edited {0}"
|
||||||
|
|
||||||
@@ -869,8 +878,8 @@ msgstr "edited {0}"
|
|||||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||||
#: src/components/chat/ChatMessageItem.tsx:128
|
#: src/components/chat/ChatMessageItem.tsx:128
|
||||||
#: src/components/comment/CommentNode.tsx:203
|
#: src/components/comment/CommentNode.tsx:203
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:97
|
#: src/components/playlist/PlaylistHeader.tsx:156
|
||||||
#: src/pages/Dump.tsx:574
|
#: src/pages/Dump.tsx:490
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
msgstr "Edited {0}"
|
msgstr "Edited {0}"
|
||||||
|
|
||||||
@@ -936,8 +945,6 @@ msgstr "Failed to post reply"
|
|||||||
#: src/components/federation/GrantList.tsx:98
|
#: src/components/federation/GrantList.tsx:98
|
||||||
#: src/components/federation/PeerManager.tsx:158
|
#: src/components/federation/PeerManager.tsx:158
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:207
|
#: src/components/playlist/PlaylistEditForm.tsx:207
|
||||||
#: src/components/profile/DescriptionEditor.tsx:99
|
|
||||||
#: src/components/profile/EmailEditor.tsx:86
|
|
||||||
msgid "Failed to save"
|
msgid "Failed to save"
|
||||||
msgstr "Failed to save"
|
msgstr "Failed to save"
|
||||||
|
|
||||||
@@ -949,7 +956,7 @@ msgstr "Failed to save category"
|
|||||||
msgid "Failed to save edit"
|
msgid "Failed to save edit"
|
||||||
msgstr "Failed to save edit"
|
msgstr "Failed to save edit"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:182
|
#: src/components/profile/ProfileHeader.tsx:174
|
||||||
msgid "Failed to update avatar"
|
msgid "Failed to update avatar"
|
||||||
msgstr "Failed to update avatar"
|
msgstr "Failed to update avatar"
|
||||||
|
|
||||||
@@ -1064,7 +1071,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
|
|||||||
msgid "image"
|
msgid "image"
|
||||||
msgstr "image"
|
msgstr "image"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:643
|
#: src/pages/Dump.tsx:576
|
||||||
msgid "In collections"
|
msgid "In collections"
|
||||||
msgstr "In collections"
|
msgstr "In collections"
|
||||||
|
|
||||||
@@ -1095,7 +1102,7 @@ msgstr "Invalid link"
|
|||||||
msgid "Invite link"
|
msgid "Invite link"
|
||||||
msgstr "Invite link"
|
msgstr "Invite link"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:126
|
#: src/components/profile/ProfileHeader.tsx:133
|
||||||
msgid "invited by"
|
msgid "invited by"
|
||||||
msgstr "invited by"
|
msgstr "invited by"
|
||||||
|
|
||||||
@@ -1165,7 +1172,7 @@ msgstr "Load more"
|
|||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Load older messages"
|
msgstr "Load older messages"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:359
|
#: src/pages/Dump.tsx:358
|
||||||
#: src/pages/DumpEdit.tsx:210
|
#: src/pages/DumpEdit.tsx:210
|
||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Loading dump…"
|
msgstr "Loading dump…"
|
||||||
@@ -1178,7 +1185,7 @@ msgstr "Loading messages…"
|
|||||||
msgid "Loading more…"
|
msgid "Loading more…"
|
||||||
msgstr "Loading more…"
|
msgstr "Loading more…"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:82
|
#: src/pages/PlaylistDetail.tsx:83
|
||||||
msgid "Loading playlist…"
|
msgid "Loading playlist…"
|
||||||
msgstr "Loading playlist…"
|
msgstr "Loading playlist…"
|
||||||
|
|
||||||
@@ -1548,9 +1555,9 @@ msgstr "Previous track"
|
|||||||
#: src/components/dump/DumpCard.tsx:144
|
#: src/components/dump/DumpCard.tsx:144
|
||||||
#: src/components/dump/JournalCard.tsx:146
|
#: src/components/dump/JournalCard.tsx:146
|
||||||
#: src/components/playlist/PlaylistCard.tsx:127
|
#: src/components/playlist/PlaylistCard.tsx:127
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:81
|
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
||||||
#: src/pages/Dump.tsx:582
|
#: src/pages/Dump.tsx:498
|
||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "private"
|
msgstr "private"
|
||||||
|
|
||||||
@@ -1559,7 +1566,7 @@ msgid "Private"
|
|||||||
msgstr "Private"
|
msgstr "Private"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:126
|
#: src/components/playlist/PlaylistCard.tsx:126
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:81
|
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
@@ -1593,7 +1600,7 @@ msgstr "Registering…"
|
|||||||
msgid "Registration failed"
|
msgid "Registration failed"
|
||||||
msgstr "Registration failed"
|
msgstr "Registration failed"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:634
|
#: src/pages/Dump.tsx:567
|
||||||
msgid "Related"
|
msgid "Related"
|
||||||
msgstr "Related"
|
msgstr "Related"
|
||||||
|
|
||||||
@@ -1680,7 +1687,7 @@ msgid "email.reset.title"
|
|||||||
msgstr "Reset your password"
|
msgstr "Reset your password"
|
||||||
|
|
||||||
#: src/components/chat/PendingItem.tsx:86
|
#: src/components/chat/PendingItem.tsx:86
|
||||||
#: src/pages/Dump.tsx:393
|
#: src/pages/Dump.tsx:392
|
||||||
#: src/pages/DumpEdit.tsx:227
|
#: src/pages/DumpEdit.tsx:227
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Retry"
|
msgstr "Retry"
|
||||||
@@ -1692,20 +1699,16 @@ msgstr "Role"
|
|||||||
#: src/components/category/CategoryManager.tsx:232
|
#: src/components/category/CategoryManager.tsx:232
|
||||||
#: src/components/chat/ChatEditBox.tsx:85
|
#: src/components/chat/ChatEditBox.tsx:85
|
||||||
#: src/components/comment/CommentNode.tsx:216
|
#: src/components/comment/CommentNode.tsx:216
|
||||||
|
#: src/components/form/InlineEdit.tsx:269
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:229
|
#: src/components/playlist/PlaylistEditForm.tsx:229
|
||||||
#: src/components/profile/DescriptionEditor.tsx:89
|
|
||||||
#: src/components/profile/EmailEditor.tsx:75
|
|
||||||
#: src/pages/Dump.tsx:508
|
|
||||||
#: src/pages/DumpEdit.tsx:564
|
#: src/pages/DumpEdit.tsx:564
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Save"
|
msgstr "Save"
|
||||||
|
|
||||||
#: src/components/comment/CommentNode.tsx:217
|
#: src/components/comment/CommentNode.tsx:217
|
||||||
|
#: src/components/form/InlineEdit.tsx:266
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:226
|
#: src/components/playlist/PlaylistEditForm.tsx:226
|
||||||
#: src/components/profile/ChangePasswordModal.tsx:119
|
#: src/components/profile/ChangePasswordModal.tsx:119
|
||||||
#: src/components/profile/DescriptionEditor.tsx:86
|
|
||||||
#: src/components/profile/EmailEditor.tsx:72
|
|
||||||
#: src/pages/Dump.tsx:507
|
|
||||||
#: src/pages/ResetPassword.tsx:191
|
#: src/pages/ResetPassword.tsx:191
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Saving…"
|
msgstr "Saving…"
|
||||||
@@ -1815,7 +1818,7 @@ msgstr "Someone asked to reset the password of your {site} account. The button b
|
|||||||
#: src/components/form/FormError.tsx:31
|
#: src/components/form/FormError.tsx:31
|
||||||
#: src/components/layout/PageError.tsx:29
|
#: src/components/layout/PageError.tsx:29
|
||||||
#: src/contexts/ws/WSProvider.tsx:738
|
#: src/contexts/ws/WSProvider.tsx:738
|
||||||
#: src/pages/PlaylistDetail.tsx:155
|
#: src/pages/PlaylistDetail.tsx:157
|
||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Something went wrong"
|
msgstr "Something went wrong"
|
||||||
|
|
||||||
@@ -1879,7 +1882,7 @@ msgstr "The new order couldn't be saved. Try again."
|
|||||||
msgid "This comment couldn't be deleted. Try again."
|
msgid "This comment couldn't be deleted. Try again."
|
||||||
msgstr "This comment couldn't be deleted. Try again."
|
msgstr "This comment couldn't be deleted. Try again."
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:368
|
#: src/pages/Dump.tsx:367
|
||||||
msgid "This dump was deleted, or made private."
|
msgid "This dump was deleted, or made private."
|
||||||
msgstr "This dump was deleted, or made private."
|
msgstr "This dump was deleted, or made private."
|
||||||
|
|
||||||
@@ -2033,7 +2036,7 @@ msgstr "updated {ago}"
|
|||||||
msgid "email.digest.upload"
|
msgid "email.digest.upload"
|
||||||
msgstr "upload"
|
msgstr "upload"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:79
|
#: src/components/profile/ProfileHeader.tsx:80
|
||||||
msgid "Upload failed"
|
msgid "Upload failed"
|
||||||
msgstr "Upload failed"
|
msgstr "Upload failed"
|
||||||
|
|
||||||
@@ -2136,12 +2139,14 @@ msgid "email.welcome.subject"
|
|||||||
msgstr "Welcome to {site}"
|
msgstr "Welcome to {site}"
|
||||||
|
|
||||||
#: src/components/dump/create/DumpCreateModal.tsx:705
|
#: src/components/dump/create/DumpCreateModal.tsx:705
|
||||||
|
#: src/pages/Dump.tsx:514
|
||||||
|
#: src/pages/Dump.tsx:521
|
||||||
#: src/pages/DumpEdit.tsx:539
|
#: src/pages/DumpEdit.tsx:539
|
||||||
msgid "What makes it worth it?"
|
msgid "What makes it worth it?"
|
||||||
msgstr "What makes it worth it?"
|
msgstr "What makes it worth it?"
|
||||||
|
|
||||||
#: src/components/profile/DescriptionEditor.tsx:78
|
#: src/components/profile/ProfileDescription.tsx:62
|
||||||
#: src/components/profile/ProfileDescription.tsx:79
|
#: src/components/profile/ProfileDescription.tsx:72
|
||||||
msgid "Who am I?"
|
msgid "Who am I?"
|
||||||
msgstr "Who am I?"
|
msgstr "Who am I?"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
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 !"
|
"Votre compte a bien été créé. Bienvenue à bord !"
|
||||||
|
|
||||||
#: src/components/dump/create/DumpCreateModal.tsx:736
|
#: src/components/dump/create/DumpCreateModal.tsx:736
|
||||||
#: src/pages/PlaylistDetail.tsx:98
|
#: src/pages/PlaylistDetail.tsx:99
|
||||||
#: src/pages/UserPublicProfile.tsx:129
|
#: src/pages/UserPublicProfile.tsx:129
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Retour"
|
msgstr "← Retour"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:375
|
#: src/pages/Dump.tsx:374
|
||||||
#: src/pages/Dump.tsx:400
|
#: src/pages/Dump.tsx:399
|
||||||
#: src/pages/Dump.tsx:627
|
#: src/pages/Dump.tsx:560
|
||||||
#: src/pages/DumpEdit.tsx:234
|
#: src/pages/DumpEdit.tsx:234
|
||||||
msgid "← Back to all dumps"
|
msgid "← Back to all dumps"
|
||||||
msgstr "← Retour à toutes les recos"
|
msgstr "← Retour à toutes les recos"
|
||||||
@@ -226,7 +226,7 @@ msgstr "+ Inviter quelqu'un"
|
|||||||
msgid "+ New playlist"
|
msgid "+ New playlist"
|
||||||
msgstr "+ Nouvelle collection"
|
msgstr "+ Nouvelle collection"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:474
|
#: src/pages/Dump.tsx:451
|
||||||
msgid "+ Playlist"
|
msgid "+ Playlist"
|
||||||
msgstr "+ Collection"
|
msgstr "+ Collection"
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
|||||||
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
|
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: event.username
|
#. placeholder {0}: event.username
|
||||||
#: src/components/social/CommunityFeed.tsx:144
|
#: src/components/social/CommunityFeed.tsx:145
|
||||||
msgid "<0>{0}</0> joined"
|
msgid "<0>{0}</0> joined"
|
||||||
msgstr "<0>{0}</0> a rejoint le site"
|
msgstr "<0>{0}</0> a rejoint le site"
|
||||||
|
|
||||||
@@ -311,16 +311,16 @@ msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
|||||||
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
|
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
|
||||||
msgstr "<0>{name}</0> ({host}) demande à vous connecter avec votre compte d'ici, <1>@{username}</1>. Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar."
|
msgstr "<0>{name}</0> ({host}) demande à vous connecter avec votre compte d'ici, <1>@{username}</1>. Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar."
|
||||||
|
|
||||||
#: src/components/social/CommunityFeed.tsx:155
|
#: src/components/social/CommunityFeed.tsx:159
|
||||||
msgid "<0>Online now:</0> {names}"
|
msgid "<0>Online now:</0> {names}"
|
||||||
msgstr "<0>En ligne :</0> {names}"
|
msgstr "<0>En ligne :</0> {names}"
|
||||||
|
|
||||||
#: src/components/social/CommunityFeed.tsx:162
|
#: src/components/social/CommunityFeed.tsx:166
|
||||||
msgid "<0>This week:</0> <1>{figures}</1>"
|
msgid "<0>This week:</0> <1>{figures}</1>"
|
||||||
msgstr "<0>Cette semaine :</0> <1>{figures}</1>"
|
msgstr "<0>Cette semaine :</0> <1>{figures}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: top.title
|
#. placeholder {0}: top.title
|
||||||
#: src/components/social/CommunityFeed.tsx:170
|
#: src/components/social/CommunityFeed.tsx:174
|
||||||
msgid "<0>Top this week:</0> <1>{0}</1>"
|
msgid "<0>Top this week:</0> <1>{0}</1>"
|
||||||
msgstr "<0>En tête cette semaine :</0> <1>{0}</1>"
|
msgstr "<0>En tête cette semaine :</0> <1>{0}</1>"
|
||||||
|
|
||||||
@@ -344,7 +344,7 @@ msgstr "une publication"
|
|||||||
msgid "Account"
|
msgid "Account"
|
||||||
msgstr "Compte"
|
msgstr "Compte"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:144
|
#: src/components/profile/ProfileHeader.tsx:151
|
||||||
msgid "Account on <0>{host}</0>"
|
msgid "Account on <0>{host}</0>"
|
||||||
msgstr "Compte sur <0>{host}</0>"
|
msgstr "Compte sur <0>{host}</0>"
|
||||||
|
|
||||||
@@ -352,12 +352,17 @@ msgstr "Compte sur <0>{host}</0>"
|
|||||||
msgid "Add a comment…"
|
msgid "Add a comment…"
|
||||||
msgstr "Ajouter un commentaire…"
|
msgstr "Ajouter un commentaire…"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistHeader.tsx:117
|
||||||
|
#: src/components/playlist/PlaylistHeader.tsx:128
|
||||||
|
msgid "Add a description…"
|
||||||
|
msgstr "Ajouter une description…"
|
||||||
|
|
||||||
#: src/components/category/CategoryManager.tsx:115
|
#: src/components/category/CategoryManager.tsx:115
|
||||||
#: src/components/category/CategoryManager.tsx:116
|
#: src/components/category/CategoryManager.tsx:116
|
||||||
msgid "Add category"
|
msgid "Add category"
|
||||||
msgstr "Ajouter une catégorie"
|
msgstr "Ajouter une catégorie"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:173
|
#: src/components/profile/ProfileHeader.tsx:168
|
||||||
msgid "Add email…"
|
msgid "Add email…"
|
||||||
msgstr "Ajouter un e-mail…"
|
msgstr "Ajouter un e-mail…"
|
||||||
|
|
||||||
@@ -470,12 +475,10 @@ msgstr "Connexion impossible"
|
|||||||
#: src/components/comment/CommentForm.tsx:127
|
#: src/components/comment/CommentForm.tsx:127
|
||||||
#: src/components/dump/create/DumpCreateModal.tsx:746
|
#: src/components/dump/create/DumpCreateModal.tsx:746
|
||||||
#: src/components/form/FormActions.tsx:42
|
#: src/components/form/FormActions.tsx:42
|
||||||
|
#: src/components/form/InlineEdit.tsx:277
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:222
|
#: src/components/playlist/PlaylistEditForm.tsx:222
|
||||||
#: src/components/profile/DescriptionEditor.tsx:97
|
|
||||||
#: src/components/profile/EmailEditor.tsx:83
|
|
||||||
#: src/components/shared/ConfirmModal.tsx:63
|
#: src/components/shared/ConfirmModal.tsx:63
|
||||||
#: src/pages/Authorize.tsx:158
|
#: src/pages/Authorize.tsx:158
|
||||||
#: src/pages/Dump.tsx:516
|
|
||||||
#: src/pages/DumpEdit.tsx:561
|
#: src/pages/DumpEdit.tsx:561
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Annuler"
|
msgstr "Annuler"
|
||||||
@@ -501,7 +504,7 @@ msgstr "Catégories"
|
|||||||
msgid "Category"
|
msgid "Category"
|
||||||
msgstr "Catégorie"
|
msgstr "Catégorie"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:102
|
#: src/components/profile/ProfileHeader.tsx:109
|
||||||
msgid "Change avatar"
|
msgid "Change avatar"
|
||||||
msgstr "Changer l'avatar"
|
msgstr "Changer l'avatar"
|
||||||
|
|
||||||
@@ -721,7 +724,7 @@ msgstr "Échec de la suppression ({0})"
|
|||||||
#: src/components/playlist/PlaylistCard.tsx:172
|
#: src/components/playlist/PlaylistCard.tsx:172
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||||
#: src/pages/PlaylistDetail.tsx:172
|
#: src/pages/PlaylistDetail.tsx:174
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Supprimer la collection"
|
msgstr "Supprimer la collection"
|
||||||
|
|
||||||
@@ -738,7 +741,7 @@ msgid "Delete this message?"
|
|||||||
msgstr "Supprimer ce message ?"
|
msgstr "Supprimer ce message ?"
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||||
#: src/pages/PlaylistDetail.tsx:171
|
#: src/pages/PlaylistDetail.tsx:173
|
||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||||
|
|
||||||
@@ -827,8 +830,8 @@ msgstr "Plus tôt"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:108
|
#: src/components/chat/ChatMessageItem.tsx:108
|
||||||
#: src/components/chat/PendingItem.tsx:93
|
#: src/components/chat/PendingItem.tsx:93
|
||||||
#: src/components/comment/CommentNode.tsx:256
|
#: src/components/comment/CommentNode.tsx:256
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:64
|
#: src/components/playlist/PlaylistHeader.tsx:104
|
||||||
#: src/pages/Dump.tsx:623
|
#: src/pages/Dump.tsx:556
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Modifier"
|
msgstr "Modifier"
|
||||||
|
|
||||||
@@ -837,16 +840,22 @@ msgstr "Modifier"
|
|||||||
msgid "Edit {0}"
|
msgid "Edit {0}"
|
||||||
msgstr "Modifier {0}"
|
msgstr "Modifier {0}"
|
||||||
|
|
||||||
#: src/components/profile/ProfileDescription.tsx:58
|
#: src/components/playlist/PlaylistHeader.tsx:113
|
||||||
|
#: src/components/profile/ProfileDescription.tsx:45
|
||||||
msgid "Edit description"
|
msgid "Edit description"
|
||||||
msgstr "Modifier la description"
|
msgstr "Modifier la description"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:171
|
#: src/components/profile/ProfileHeader.tsx:161
|
||||||
msgid "Edit email"
|
msgid "Edit email"
|
||||||
msgstr "Modifier l'e-mail"
|
msgstr "Modifier l'e-mail"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:510
|
||||||
|
msgid "Edit note"
|
||||||
|
msgstr "Modifier la note"
|
||||||
|
|
||||||
#: src/components/dump/create/TitleField.tsx:69
|
#: src/components/dump/create/TitleField.tsx:69
|
||||||
#: src/pages/Dump.tsx:533
|
#: src/components/playlist/PlaylistHeader.tsx:84
|
||||||
|
#: src/pages/Dump.tsx:459
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Modifier le titre"
|
msgstr "Modifier le titre"
|
||||||
|
|
||||||
@@ -859,8 +868,8 @@ msgstr "modifié"
|
|||||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||||
#: src/components/chat/ChatMessageItem.tsx:132
|
#: src/components/chat/ChatMessageItem.tsx:132
|
||||||
#: src/components/comment/CommentNode.tsx:205
|
#: src/components/comment/CommentNode.tsx:205
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:99
|
#: src/components/playlist/PlaylistHeader.tsx:158
|
||||||
#: src/pages/Dump.tsx:576
|
#: src/pages/Dump.tsx:492
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
msgstr "modifié {0}"
|
msgstr "modifié {0}"
|
||||||
|
|
||||||
@@ -869,8 +878,8 @@ msgstr "modifié {0}"
|
|||||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||||
#: src/components/chat/ChatMessageItem.tsx:128
|
#: src/components/chat/ChatMessageItem.tsx:128
|
||||||
#: src/components/comment/CommentNode.tsx:203
|
#: src/components/comment/CommentNode.tsx:203
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:97
|
#: src/components/playlist/PlaylistHeader.tsx:156
|
||||||
#: src/pages/Dump.tsx:574
|
#: src/pages/Dump.tsx:490
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
msgstr "Modifié le {0}"
|
msgstr "Modifié le {0}"
|
||||||
|
|
||||||
@@ -936,8 +945,6 @@ msgstr "Impossible de publier la réponse"
|
|||||||
#: src/components/federation/GrantList.tsx:98
|
#: src/components/federation/GrantList.tsx:98
|
||||||
#: src/components/federation/PeerManager.tsx:158
|
#: src/components/federation/PeerManager.tsx:158
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:207
|
#: src/components/playlist/PlaylistEditForm.tsx:207
|
||||||
#: src/components/profile/DescriptionEditor.tsx:99
|
|
||||||
#: src/components/profile/EmailEditor.tsx:86
|
|
||||||
msgid "Failed to save"
|
msgid "Failed to save"
|
||||||
msgstr "Enregistrement échoué"
|
msgstr "Enregistrement échoué"
|
||||||
|
|
||||||
@@ -949,7 +956,7 @@ msgstr "Échec de l’enregistrement de la catégorie"
|
|||||||
msgid "Failed to save edit"
|
msgid "Failed to save edit"
|
||||||
msgstr "Impossible d'enregistrer la modification"
|
msgstr "Impossible d'enregistrer la modification"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:182
|
#: src/components/profile/ProfileHeader.tsx:174
|
||||||
msgid "Failed to update avatar"
|
msgid "Failed to update avatar"
|
||||||
msgstr "Impossible de mettre à jour l'avatar"
|
msgstr "Impossible de mettre à jour l'avatar"
|
||||||
|
|
||||||
@@ -1064,7 +1071,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
|
|||||||
msgid "image"
|
msgid "image"
|
||||||
msgstr "image"
|
msgstr "image"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:643
|
#: src/pages/Dump.tsx:576
|
||||||
msgid "In collections"
|
msgid "In collections"
|
||||||
msgstr "Dans les collections"
|
msgstr "Dans les collections"
|
||||||
|
|
||||||
@@ -1095,7 +1102,7 @@ msgstr "Lien invalide"
|
|||||||
msgid "Invite link"
|
msgid "Invite link"
|
||||||
msgstr "Lien d'invitation"
|
msgstr "Lien d'invitation"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:126
|
#: src/components/profile/ProfileHeader.tsx:133
|
||||||
msgid "invited by"
|
msgid "invited by"
|
||||||
msgstr "invité par"
|
msgstr "invité par"
|
||||||
|
|
||||||
@@ -1165,7 +1172,7 @@ msgstr "Charger plus"
|
|||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Charger les messages plus anciens"
|
msgstr "Charger les messages plus anciens"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:359
|
#: src/pages/Dump.tsx:358
|
||||||
#: src/pages/DumpEdit.tsx:210
|
#: src/pages/DumpEdit.tsx:210
|
||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Chargement de la reco…"
|
msgstr "Chargement de la reco…"
|
||||||
@@ -1178,7 +1185,7 @@ msgstr "Chargement des messages…"
|
|||||||
msgid "Loading more…"
|
msgid "Loading more…"
|
||||||
msgstr "Chargement…"
|
msgstr "Chargement…"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:82
|
#: src/pages/PlaylistDetail.tsx:83
|
||||||
msgid "Loading playlist…"
|
msgid "Loading playlist…"
|
||||||
msgstr "Chargement de la collection…"
|
msgstr "Chargement de la collection…"
|
||||||
|
|
||||||
@@ -1548,9 +1555,9 @@ msgstr "Piste précédente"
|
|||||||
#: src/components/dump/DumpCard.tsx:144
|
#: src/components/dump/DumpCard.tsx:144
|
||||||
#: src/components/dump/JournalCard.tsx:146
|
#: src/components/dump/JournalCard.tsx:146
|
||||||
#: src/components/playlist/PlaylistCard.tsx:127
|
#: src/components/playlist/PlaylistCard.tsx:127
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:81
|
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
||||||
#: src/pages/Dump.tsx:582
|
#: src/pages/Dump.tsx:498
|
||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "privé"
|
msgstr "privé"
|
||||||
|
|
||||||
@@ -1559,7 +1566,7 @@ msgid "Private"
|
|||||||
msgstr "Privé"
|
msgstr "Privé"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:126
|
#: src/components/playlist/PlaylistCard.tsx:126
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:81
|
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
@@ -1593,7 +1600,7 @@ msgstr "Inscription…"
|
|||||||
msgid "Registration failed"
|
msgid "Registration failed"
|
||||||
msgstr "Inscription échouée"
|
msgstr "Inscription échouée"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:634
|
#: src/pages/Dump.tsx:567
|
||||||
msgid "Related"
|
msgid "Related"
|
||||||
msgstr "Connexe"
|
msgstr "Connexe"
|
||||||
|
|
||||||
@@ -1680,7 +1687,7 @@ msgid "email.reset.title"
|
|||||||
msgstr "Réinitialiser votre mot de passe"
|
msgstr "Réinitialiser votre mot de passe"
|
||||||
|
|
||||||
#: src/components/chat/PendingItem.tsx:86
|
#: src/components/chat/PendingItem.tsx:86
|
||||||
#: src/pages/Dump.tsx:393
|
#: src/pages/Dump.tsx:392
|
||||||
#: src/pages/DumpEdit.tsx:227
|
#: src/pages/DumpEdit.tsx:227
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Réessayer"
|
msgstr "Réessayer"
|
||||||
@@ -1692,20 +1699,16 @@ msgstr "Rôle"
|
|||||||
#: src/components/category/CategoryManager.tsx:232
|
#: src/components/category/CategoryManager.tsx:232
|
||||||
#: src/components/chat/ChatEditBox.tsx:85
|
#: src/components/chat/ChatEditBox.tsx:85
|
||||||
#: src/components/comment/CommentNode.tsx:216
|
#: src/components/comment/CommentNode.tsx:216
|
||||||
|
#: src/components/form/InlineEdit.tsx:269
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:229
|
#: src/components/playlist/PlaylistEditForm.tsx:229
|
||||||
#: src/components/profile/DescriptionEditor.tsx:89
|
|
||||||
#: src/components/profile/EmailEditor.tsx:75
|
|
||||||
#: src/pages/Dump.tsx:508
|
|
||||||
#: src/pages/DumpEdit.tsx:564
|
#: src/pages/DumpEdit.tsx:564
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Enregistrer"
|
msgstr "Enregistrer"
|
||||||
|
|
||||||
#: src/components/comment/CommentNode.tsx:217
|
#: src/components/comment/CommentNode.tsx:217
|
||||||
|
#: src/components/form/InlineEdit.tsx:266
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:226
|
#: src/components/playlist/PlaylistEditForm.tsx:226
|
||||||
#: src/components/profile/ChangePasswordModal.tsx:119
|
#: src/components/profile/ChangePasswordModal.tsx:119
|
||||||
#: src/components/profile/DescriptionEditor.tsx:86
|
|
||||||
#: src/components/profile/EmailEditor.tsx:72
|
|
||||||
#: src/pages/Dump.tsx:507
|
|
||||||
#: src/pages/ResetPassword.tsx:191
|
#: src/pages/ResetPassword.tsx:191
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Enregistrement…"
|
msgstr "Enregistrement…"
|
||||||
@@ -1815,7 +1818,7 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {
|
|||||||
#: src/components/form/FormError.tsx:31
|
#: src/components/form/FormError.tsx:31
|
||||||
#: src/components/layout/PageError.tsx:29
|
#: src/components/layout/PageError.tsx:29
|
||||||
#: src/contexts/ws/WSProvider.tsx:738
|
#: src/contexts/ws/WSProvider.tsx:738
|
||||||
#: src/pages/PlaylistDetail.tsx:155
|
#: src/pages/PlaylistDetail.tsx:157
|
||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Une erreur est survenue"
|
msgstr "Une erreur est survenue"
|
||||||
|
|
||||||
@@ -1879,7 +1882,7 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
|
|||||||
msgid "This comment couldn't be deleted. Try again."
|
msgid "This comment couldn't be deleted. Try again."
|
||||||
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
|
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:368
|
#: src/pages/Dump.tsx:367
|
||||||
msgid "This dump was deleted, or made private."
|
msgid "This dump was deleted, or made private."
|
||||||
msgstr "Cette reco a été supprimée, ou rendue privée."
|
msgstr "Cette reco a été supprimée, ou rendue privée."
|
||||||
|
|
||||||
@@ -2033,7 +2036,7 @@ msgstr "mise à jour {ago}"
|
|||||||
msgid "email.digest.upload"
|
msgid "email.digest.upload"
|
||||||
msgstr "fichier"
|
msgstr "fichier"
|
||||||
|
|
||||||
#: src/components/profile/ProfileHeader.tsx:79
|
#: src/components/profile/ProfileHeader.tsx:80
|
||||||
msgid "Upload failed"
|
msgid "Upload failed"
|
||||||
msgstr "Envoi échoué"
|
msgstr "Envoi échoué"
|
||||||
|
|
||||||
@@ -2136,12 +2139,14 @@ msgid "email.welcome.subject"
|
|||||||
msgstr "Bienvenue sur {site}"
|
msgstr "Bienvenue sur {site}"
|
||||||
|
|
||||||
#: src/components/dump/create/DumpCreateModal.tsx:705
|
#: src/components/dump/create/DumpCreateModal.tsx:705
|
||||||
|
#: src/pages/Dump.tsx:514
|
||||||
|
#: src/pages/Dump.tsx:521
|
||||||
#: src/pages/DumpEdit.tsx:539
|
#: src/pages/DumpEdit.tsx:539
|
||||||
msgid "What makes it worth it?"
|
msgid "What makes it worth it?"
|
||||||
msgstr "Pourquoi on en voudrait ?"
|
msgstr "Pourquoi on en voudrait ?"
|
||||||
|
|
||||||
#: src/components/profile/DescriptionEditor.tsx:78
|
#: src/components/profile/ProfileDescription.tsx:62
|
||||||
#: src/components/profile/ProfileDescription.tsx:79
|
#: src/components/profile/ProfileDescription.tsx:72
|
||||||
msgid "Who am I?"
|
msgid "Who am I?"
|
||||||
msgstr "Qui suis-je ?"
|
msgstr "Qui suis-je ?"
|
||||||
|
|
||||||
|
|||||||
306
src/App.css
306
src/App.css
@@ -223,67 +223,151 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-view {
|
/* ── Inline edit ── */
|
||||||
|
/* Text edited where it stands (InlineEdit.tsx). Hovered, the text sits on a
|
||||||
|
faint plate with a ✎ after it; opened, the field takes the plate's place
|
||||||
|
and the text's type, so that nothing jumps but the buttons that come under
|
||||||
|
it. The plate spills past the text, drawn under it by a pseudo-element: the
|
||||||
|
text's own box keeps its size, which a row measuring it (a playlist's title
|
||||||
|
beside its buttons) depends on. */
|
||||||
|
.inline-edit {
|
||||||
|
/* Tinted from the text, as it sits on a card or on the page alike. */
|
||||||
|
--inline-edit-plate: color-mix(in srgb, var(--color-text) 7%, transparent);
|
||||||
|
--inline-edit-pad-y: 0.1rem;
|
||||||
|
--inline-edit-pad-x: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit--block {
|
||||||
|
--inline-edit-pad-y: 0.45rem;
|
||||||
|
--inline-edit-pad-x: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.inline-edit-view {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
max-width: 100%;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-view::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(-1 * var(--inline-edit-pad-y))
|
||||||
|
calc(-1 * var(--inline-edit-pad-x));
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit--line .inline-edit-view {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4em;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
max-width: 100%;
|
|
||||||
/* Padding gives the hover box breathing room (incl. right of the pencil);
|
|
||||||
the matching negative margin keeps the title's visual position unchanged. */
|
|
||||||
padding: 0.1rem 0.45rem;
|
|
||||||
margin: -0.1rem -0.45rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-view--editable {
|
/* Room on the right for the ✎, which sits in the corner. */
|
||||||
cursor: pointer;
|
.inline-edit--block .inline-edit-view {
|
||||||
|
display: block;
|
||||||
|
padding-right: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-view--editable:hover {
|
.inline-edit-view:hover::before {
|
||||||
background: var(--color-surface);
|
background: var(--inline-edit-plate);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-edit-btn {
|
.inline-edit-mark {
|
||||||
font-size: var(--text-sm);
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding: 0.15em;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity var(--dur-fast);
|
transition: opacity var(--dur-fast);
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-view--editable:hover .dump-title-edit-btn,
|
.inline-edit--block .inline-edit-mark {
|
||||||
.dump-title-view--editable:focus-visible .dump-title-edit-btn {
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-view:hover .inline-edit-mark,
|
||||||
|
.inline-edit-mark:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-editor {
|
/* No pointer to hover with: the mark stays, faintly, to say it can be
|
||||||
|
tapped. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.inline-edit-mark {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What an empty text says to the one who may fill it. */
|
||||||
|
.inline-edit-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typed as the text it replaces: the type is inherited at no specificity, so
|
||||||
|
that the class of that text (`fieldClassName`) and a style's rules for it
|
||||||
|
both set it over this. */
|
||||||
|
:where(.inline-edit-input) {
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: calc(100% + 2 * var(--inline-edit-pad-x));
|
||||||
|
min-width: 0;
|
||||||
|
padding: calc(var(--inline-edit-pad-y) - var(--border-width))
|
||||||
|
calc(var(--inline-edit-pad-x) - var(--border-width));
|
||||||
|
margin: calc(-1 * var(--inline-edit-pad-y))
|
||||||
|
calc(-1 * var(--inline-edit-pad-x));
|
||||||
|
border: var(--border-width) solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--inline-edit-plate);
|
||||||
|
transition: border-color var(--dur-base), box-shadow var(--dur-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* As the Markdown editor's box takes the focus. */
|
||||||
|
.inline-edit-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px
|
||||||
|
color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared chrome for inline-edit inputs (the editors that live outside `.form`).
|
.inline-edit-error {
|
||||||
Each rule below adds only its own size / weight / width deltas. */
|
margin: 0;
|
||||||
.dump-title-input,
|
font-size: var(--text-sm);
|
||||||
.profile-email-input,
|
color: var(--color-danger);
|
||||||
.username-field-input {
|
|
||||||
border: var(--border-width) solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: var(--color-surface-sunken);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color var(--dur-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dump-title-input:focus,
|
|
||||||
.profile-email-input:focus,
|
|
||||||
.username-field-input:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A name and its "Rename" button, the hint and errors below. */
|
/* A name and its "Rename" button, the hint and errors below. */
|
||||||
@@ -304,28 +388,18 @@
|
|||||||
flex: 1 1 10rem;
|
flex: 1 1 10rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-sunken);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: inherit;
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--dur-slow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-input {
|
.username-field-input:focus {
|
||||||
flex: 1;
|
border-color: var(--color-accent);
|
||||||
min-width: 12rem;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Save and Cancel wrap together, under the input on a phone. */
|
|
||||||
.dump-title-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dump-title-error {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On the narrowest viewports the fixed-width action column squeezes the title
|
/* On the narrowest viewports the fixed-width action column squeezes the title
|
||||||
@@ -2357,23 +2431,9 @@ body.has-player .chat-fab {
|
|||||||
/* No margins of its own: the page's gap already sets it apart. */
|
/* No margins of its own: the page's gap already sets it apart. */
|
||||||
.profile-description {
|
.profile-description {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-view {
|
|
||||||
position: relative;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-md);
|
font-size: var(--text-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-description-view--editable {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-view--editable:hover {
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-text {
|
.profile-description-text {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
@@ -2381,80 +2441,16 @@ body.has-player .chat-fab {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-description-empty {
|
.profile-email {
|
||||||
color: var(--color-muted);
|
margin: 0.1rem 0 0.4rem;
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-edit-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.55rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-muted);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--dur-fast);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-view--editable:hover .profile-description-edit-btn,
|
|
||||||
.profile-description-view--editable:focus-visible .profile-description-edit-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-email-display {
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin: 0.1rem 0 0.4rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.1rem 0.2rem;
|
|
||||||
margin-left: -0.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-email-display:hover {
|
/* An address is typed a size up from the line it shows on. */
|
||||||
background: var(--color-surface);
|
.profile-email .inline-edit-input {
|
||||||
}
|
max-width: 22rem;
|
||||||
|
|
||||||
.profile-email-display:hover .profile-description-edit-btn,
|
|
||||||
.profile-email-display:focus-visible .profile-description-edit-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-email-editor {
|
|
||||||
margin: 0.2rem 0 0.4rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-email-input {
|
|
||||||
width: min(260px, 100%);
|
|
||||||
padding: 0.35rem 0.6rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-email-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-invited-by {
|
.profile-invited-by {
|
||||||
@@ -5125,7 +5121,10 @@ body.has-player .chat-fab {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The input rests on one line, as tall as the Send button beside it: both
|
||||||
|
take --chat-composer-height, which any style's line of text fits in. */
|
||||||
.chat-composer {
|
.chat-composer {
|
||||||
|
--chat-composer-height: 2.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -5154,6 +5153,10 @@ body.has-player .chat-fab {
|
|||||||
above the input, in the gap over the composer: inside the textarea, the text
|
above the input, in the gap over the composer: inside the textarea, the text
|
||||||
ran under them, and in a row below they would knock the Send button out of
|
ran under them, and in a row below they would knock the Send button out of
|
||||||
alignment. */
|
alignment. */
|
||||||
|
.chat-composer .chat-input {
|
||||||
|
min-height: var(--chat-composer-height);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-composer .text-editor-footer {
|
.chat-composer .text-editor-footer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.25rem;
|
left: 0.25rem;
|
||||||
@@ -5168,9 +5171,9 @@ body.has-player .chat-fab {
|
|||||||
|
|
||||||
.chat-send-btn {
|
.chat-send-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
/* Match the input's resting height so the two line up exactly (both edges),
|
/* The input's resting height, so that the two line up at both edges, while
|
||||||
while the input is free to grow taller as you type. */
|
the input is free to grow taller as you type. */
|
||||||
height: 2.4rem;
|
height: var(--chat-composer-height);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5518,6 +5521,23 @@ body.has-player .chat-fab {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The open title field takes the room its buttons leave beside it, which
|
||||||
|
line up with its top rather than with its Save and Cancel. */
|
||||||
|
.playlist-detail-title-row:has(> .inline-edit--editing) {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-detail-title-row > .inline-edit--editing {
|
||||||
|
flex: 1 1 16rem;
|
||||||
|
/* Clear of the buttons by the gap, past the field's spill. */
|
||||||
|
margin-right: var(--inline-edit-pad-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The title on the left, Follow and Edit together at the row's end. */
|
||||||
|
.playlist-detail-title-row > :first-child:not(.inline-edit--editing) {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-detail-title {
|
.playlist-detail-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
|
|||||||
@@ -850,7 +850,7 @@ export function ChatModal(
|
|||||||
value={draft}
|
value={draft}
|
||||||
onChange={handleDraftChange}
|
onChange={handleDraftChange}
|
||||||
placeholder={t`Type a message…`}
|
placeholder={t`Type a message…`}
|
||||||
rows={2}
|
rows={1}
|
||||||
autoResize
|
autoResize
|
||||||
maxLength={VALIDATION.CHAT_MESSAGE_MAX}
|
maxLength={VALIDATION.CHAT_MESSAGE_MAX}
|
||||||
countFrom={COUNT_FROM}
|
countFrom={COUNT_FROM}
|
||||||
|
|||||||
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
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts";
|
import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts";
|
||||||
import type { OnlineUser } from "../../../shared/ws.ts";
|
import type { OnlineUser } from "../../../shared/ws.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
@@ -24,11 +30,16 @@ function onMotionChange(callback: () => void): () => void {
|
|||||||
return () => query.removeEventListener("change", callback);
|
return () => query.removeEventListener("change", callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the crawl began its current loop. It is kept out of the component,
|
// Where the crawl had got to, and what its loop showed, when the marquee was
|
||||||
// which every page mounts anew with its header: the next page's marquee
|
// last on screen. They are kept out of the component, which every page mounts
|
||||||
// carries on from where the last one's had got to, rather than from the
|
// anew with its header: the next page's marquee carries on from that point of
|
||||||
// start.
|
// that loop, rather than from the start. A crawl that was running has gone on
|
||||||
let loopStart: number | undefined;
|
// since; one paused under the pointer, which has just clicked a link in it,
|
||||||
|
// hasn't.
|
||||||
|
let lastPosition:
|
||||||
|
| { elapsed: number; at: number; running: boolean }
|
||||||
|
| undefined;
|
||||||
|
let lastLoop: Loop | null = null;
|
||||||
|
|
||||||
/** What one loop of the crawl shows, from its start to its end. */
|
/** What one loop of the crawl shows, from its start to its end. */
|
||||||
interface Loop {
|
interface Loop {
|
||||||
@@ -61,8 +72,12 @@ function FeedMarquee(): React.JSX.Element {
|
|||||||
onMotionChange,
|
onMotionChange,
|
||||||
() => matchMedia(REDUCED_MOTION).matches,
|
() => matchMedia(REDUCED_MOTION).matches,
|
||||||
);
|
);
|
||||||
const [loop, setLoop] = useState<Loop | null>(null);
|
// A marquee with no feed yet is a first one, or follows a sign-out.
|
||||||
|
const [loop, setLoop] = useState<Loop | null>(() => feed && lastLoop);
|
||||||
if (!loop && feed) setLoop({ feed, online, turn: 0 });
|
if (!loop && feed) setLoop({ feed, online, turn: 0 });
|
||||||
|
useEffect(() => {
|
||||||
|
lastLoop = loop;
|
||||||
|
}, [loop]);
|
||||||
const shown = still ? feed && { feed, online } : loop;
|
const shown = still ? feed && { feed, online } : loop;
|
||||||
|
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -71,16 +86,32 @@ function FeedMarquee(): React.JSX.Element {
|
|||||||
const length = track?.firstElementChild?.getBoundingClientRect().width;
|
const length = track?.firstElementChild?.getBoundingClientRect().width;
|
||||||
if (!track || !length) return;
|
if (!track || !length) return;
|
||||||
track.style.setProperty("--marquee-length", String(Math.round(length)));
|
track.style.setProperty("--marquee-length", String(Math.round(length)));
|
||||||
loopStart ??= performance.now();
|
const last = lastPosition;
|
||||||
|
lastPosition = undefined;
|
||||||
|
const elapsed = !last
|
||||||
|
? 0
|
||||||
|
: last.elapsed + (last.running ? performance.now() - last.at : 0);
|
||||||
// A negative delay starts the animation that far in.
|
// A negative delay starts the animation that far in.
|
||||||
track.style.animationDelay = `${(loopStart - performance.now()) / 1000}s`;
|
track.style.animationDelay = `${-elapsed / 1000}s`;
|
||||||
|
return () => {
|
||||||
|
// The track's own animation: the focus can have stopped it.
|
||||||
|
const animation = track.getAnimations()[0];
|
||||||
|
const timing = animation?.effect?.getComputedTiming();
|
||||||
|
if (!timing?.progress || typeof timing.duration !== "number") return;
|
||||||
|
lastPosition = {
|
||||||
|
elapsed: timing.progress * timing.duration,
|
||||||
|
at: performance.now(),
|
||||||
|
running: animation.playState === "running",
|
||||||
|
};
|
||||||
|
};
|
||||||
}, [loop?.turn, still]);
|
}, [loop?.turn, still]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="site-marquee"
|
className="site-marquee"
|
||||||
// A focused link stops the crawl, and the browser scrolls the line to
|
// A link focused from the keyboard stops the crawl, and the browser
|
||||||
// show it: the line goes back to its start once the focus has left.
|
// scrolls the line to show it: the line goes back to its start once the
|
||||||
|
// focus has left.
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
e.currentTarget.scrollLeft = 0;
|
e.currentTarget.scrollLeft = 0;
|
||||||
@@ -95,7 +126,6 @@ function FeedMarquee(): React.JSX.Element {
|
|||||||
onAnimationIteration={(e) => {
|
onAnimationIteration={(e) => {
|
||||||
// Not a child's animation ending a round of its own.
|
// Not a child's animation ending a round of its own.
|
||||||
if (e.target !== e.currentTarget || !feed || !loop) return;
|
if (e.target !== e.currentTarget || !feed || !loop) return;
|
||||||
loopStart = performance.now();
|
|
||||||
setLoop({ feed, online, turn: loop.turn + 1 });
|
setLoop({ feed, online, turn: loop.turn + 1 });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,12 +8,22 @@
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import type { PlaylistWithDumps } from "../../../shared/playlist.ts";
|
import { API_URL } from "../../config/api.ts";
|
||||||
|
import { VALIDATION } from "../../../shared/validation.ts";
|
||||||
|
import type { Wire } from "../../../shared/api.ts";
|
||||||
|
import type {
|
||||||
|
Playlist,
|
||||||
|
PlaylistWithDumps,
|
||||||
|
UpdatePlaylistRequest,
|
||||||
|
} from "../../../shared/playlist.ts";
|
||||||
|
import { expectOk } from "../../utils/shared/api.ts";
|
||||||
|
import { deserializePlaylist } from "../../utils/shared/deserialize.ts";
|
||||||
import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts";
|
import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts";
|
||||||
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
||||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
import { Markdown } from "../editor/Markdown.tsx";
|
import { Markdown } from "../editor/Markdown.tsx";
|
||||||
|
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||||
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
|
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
|
||||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||||
import { CategoryChips } from "../category/CategoryChips.tsx";
|
import { CategoryChips } from "../category/CategoryChips.tsx";
|
||||||
@@ -22,18 +32,38 @@ import { userPath } from "../../../shared/paths.ts";
|
|||||||
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
|
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
|
||||||
export interface PlaylistHeaderProps {
|
export interface PlaylistHeaderProps {
|
||||||
playlist: PlaylistWithDumps;
|
playlist: PlaylistWithDumps;
|
||||||
/** Given to those who may edit it, for whom it adds an "Edit" button. */
|
/**
|
||||||
|
* Given to those who may edit it, with `onSaved`: it adds an "Edit" button,
|
||||||
|
* which opens `PlaylistEditForm`.
|
||||||
|
*/
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
/**
|
||||||
|
* Called with the playlist as the server has it once its title or
|
||||||
|
* description is edited in place, which it lets its editors do.
|
||||||
|
*/
|
||||||
|
onSaved?: (playlist: Playlist) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the playlist's details, with a follow button for anyone but its
|
* Renders the playlist's details, with a follow button for anyone but its
|
||||||
* owner. `PlaylistEditForm` takes its place while it is edited.
|
* owner. Its editors change the title and the description in place, saved to
|
||||||
|
* `PUT /api/playlists/:playlistId`, and the rest in `PlaylistEditForm`, which
|
||||||
|
* takes the header's place while it is open.
|
||||||
*/
|
*/
|
||||||
export function PlaylistHeader(
|
export function PlaylistHeader(
|
||||||
{ playlist, onEdit }: PlaylistHeaderProps,
|
{ playlist, onEdit, onSaved }: PlaylistHeaderProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const { user, mediaToken } = useAuth();
|
const { user, mediaToken, authFetch } = useAuth();
|
||||||
|
const save = async (changes: UpdatePlaylistRequest) => {
|
||||||
|
const updated = await expectOk<Wire<Playlist>>(
|
||||||
|
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onSaved?.(deserializePlaylist(updated));
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{playlist.imageMime && (
|
{playlist.imageMime && (
|
||||||
@@ -48,7 +78,17 @@ export function PlaylistHeader(
|
|||||||
)}
|
)}
|
||||||
<div className="playlist-detail-content">
|
<div className="playlist-detail-content">
|
||||||
<div className="playlist-detail-title-row">
|
<div className="playlist-detail-title-row">
|
||||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
<InlineEdit
|
||||||
|
value={playlist.title}
|
||||||
|
editable={!!onSaved}
|
||||||
|
label={t`Edit title`}
|
||||||
|
onSave={(title) => save({ title })}
|
||||||
|
required
|
||||||
|
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||||
|
fieldClassName="playlist-detail-title"
|
||||||
|
>
|
||||||
|
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||||
|
</InlineEdit>
|
||||||
{user?.id !== playlist.userId && (
|
{user?.id !== playlist.userId && (
|
||||||
<FollowPlaylistButton
|
<FollowPlaylistButton
|
||||||
targetPlaylistId={playlist.id}
|
targetPlaylistId={playlist.id}
|
||||||
@@ -66,10 +106,29 @@ export function PlaylistHeader(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{playlist.description && (
|
{(playlist.description || onSaved) && (
|
||||||
<Markdown className="playlist-detail-description">
|
<InlineEdit
|
||||||
{playlist.description}
|
value={playlist.description ?? ""}
|
||||||
</Markdown>
|
editable={!!onSaved}
|
||||||
|
label={t`Edit description`}
|
||||||
|
onSave={(description) => save({ description: description || null })}
|
||||||
|
kind="markdown"
|
||||||
|
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||||
|
placeholder={t`Add a description…`}
|
||||||
|
|
||||||
|
>
|
||||||
|
{playlist.description
|
||||||
|
? (
|
||||||
|
<Markdown className="playlist-detail-description">
|
||||||
|
{playlist.description}
|
||||||
|
</Markdown>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<p className="inline-edit-empty">
|
||||||
|
<Trans>Add a description…</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</InlineEdit>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="playlist-detail-meta">
|
<div className="playlist-detail-meta">
|
||||||
|
|||||||
@@ -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
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
|
import { API_URL } from "../../config/api.ts";
|
||||||
|
import { VALIDATION } from "../../../shared/validation.ts";
|
||||||
|
import type { Wire } from "../../../shared/api.ts";
|
||||||
|
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
|
||||||
|
import { expectOk } from "../../utils/shared/api.ts";
|
||||||
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
|
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||||
import { Markdown } from "../editor/Markdown.tsx";
|
import { Markdown } from "../editor/Markdown.tsx";
|
||||||
import { DescriptionEditor } from "./DescriptionEditor.tsx";
|
|
||||||
|
|
||||||
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
|
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
|
||||||
export interface ProfileDescriptionProps {
|
export interface ProfileDescriptionProps {
|
||||||
@@ -22,70 +26,53 @@ export interface ProfileDescriptionProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the description in Markdown, or nothing when there is none. On
|
* Renders the description in Markdown, or nothing when there is none. On
|
||||||
* their own profile, users get a "Who am I?" placeholder instead, and a click
|
* their own profile, users get a "Who am I?" placeholder instead, and edit it
|
||||||
* or Enter swaps in {@link DescriptionEditor}.
|
* in place, saved to `PATCH /api/me`. Other browsers see the change through
|
||||||
|
* the socket's `user_updated` event.
|
||||||
*/
|
*/
|
||||||
export function ProfileDescription(
|
export function ProfileDescription(
|
||||||
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps,
|
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps,
|
||||||
): React.JSX.Element | null {
|
): React.JSX.Element | null {
|
||||||
const [editing, setEditing] = useState(false);
|
const { authFetch } = useAuth();
|
||||||
const ref = useHeightTransition<HTMLDivElement>(editing);
|
|
||||||
|
|
||||||
if (!description && !isOwnProfile) return null;
|
if (!description && !isOwnProfile) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="profile-description" ref={ref}>
|
<div className="profile-description">
|
||||||
{editing
|
<InlineEdit
|
||||||
? (
|
value={description ?? ""}
|
||||||
<DescriptionEditor
|
editable={isOwnProfile}
|
||||||
initialDescription={description ?? ""}
|
label={t`Edit description`}
|
||||||
onCancel={() => setEditing(false)}
|
onSave={async (saved) => {
|
||||||
onSaved={(saved) => {
|
// Sent even when empty, since the server keeps the old one when it
|
||||||
onSaved(saved);
|
// is left out.
|
||||||
setEditing(false);
|
await expectOk<Wire<PublicUser>>(
|
||||||
}}
|
await authFetch(`${API_URL}/api/me`, {
|
||||||
/>
|
method: "PATCH",
|
||||||
)
|
headers: { "Content-Type": "application/json" },
|
||||||
: (
|
body: JSON.stringify(
|
||||||
<div
|
{ description: saved } satisfies UpdateUserRequest,
|
||||||
className={`profile-description-view${
|
),
|
||||||
isOwnProfile ? " profile-description-view--editable" : ""
|
}),
|
||||||
}`}
|
);
|
||||||
onClick={isOwnProfile ? () => setEditing(true) : undefined}
|
onSaved(saved || undefined);
|
||||||
// Focusable rather than a button, so the Markdown inside keeps its
|
}}
|
||||||
// own structure and links.
|
kind="markdown"
|
||||||
tabIndex={isOwnProfile ? 0 : undefined}
|
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||||
title={isOwnProfile ? t`Edit description` : undefined}
|
placeholder={t`Who am I?`}
|
||||||
onKeyDown={isOwnProfile
|
>
|
||||||
? (e) => {
|
{description
|
||||||
if (
|
? (
|
||||||
(e.key === "Enter" || e.key === " ") &&
|
<Markdown className="profile-description-text">
|
||||||
e.target === e.currentTarget
|
{description}
|
||||||
) {
|
</Markdown>
|
||||||
e.preventDefault();
|
)
|
||||||
setEditing(true);
|
: (
|
||||||
}
|
<p className="inline-edit-empty">
|
||||||
}
|
<Trans>Who am I?</Trans>
|
||||||
: undefined}
|
</p>
|
||||||
>
|
)}
|
||||||
{description
|
</InlineEdit>
|
||||||
? (
|
|
||||||
<Markdown className="profile-description-text">
|
|
||||||
{description}
|
|
||||||
</Markdown>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="profile-description-empty">
|
|
||||||
<Trans>Who am I?</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isOwnProfile && (
|
|
||||||
<span className="profile-description-edit-btn" aria-hidden>
|
|
||||||
✎
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { API_URL } from "../../config/api.ts";
|
import { API_URL } from "../../config/api.ts";
|
||||||
import type { Wire } from "../../../shared/api.ts";
|
import type { Wire } from "../../../shared/api.ts";
|
||||||
import type { PublicUser, SelfUser } from "../../../shared/user.ts";
|
import type {
|
||||||
import { parseAPIResponse } from "../../utils/shared/api.ts";
|
PublicUser,
|
||||||
|
SelfUser,
|
||||||
|
UpdateUserRequest,
|
||||||
|
} from "../../../shared/user.ts";
|
||||||
|
import { expectOk, parseAPIResponse } from "../../utils/shared/api.ts";
|
||||||
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
|
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
|
|
||||||
import { Avatar } from "../shared/Avatar.tsx";
|
import { Avatar } from "../shared/Avatar.tsx";
|
||||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||||
import { FollowUserButton } from "../social/FollowButton.tsx";
|
import { FollowUserButton } from "../social/FollowButton.tsx";
|
||||||
import { EmailEditor } from "./EmailEditor.tsx";
|
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||||
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
|
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
|
||||||
import { RoleChip } from "./RoleSwitcher.tsx";
|
import { RoleChip } from "./RoleSwitcher.tsx";
|
||||||
import { userPath } from "../../../shared/paths.ts";
|
import { userPath } from "../../../shared/paths.ts";
|
||||||
@@ -38,7 +41,7 @@ export interface ProfileHeaderProps {
|
|||||||
* Renders the avatar, username, role and inviter, and for a user from another
|
* Renders the avatar, username, role and inviter, and for a user from another
|
||||||
* instance, a link to their profile there. Visitors get a follow button. On
|
* instance, a link to their profile there. Visitors get a follow button. On
|
||||||
* their own profile, users change their avatar (`PUT /api/me/avatar`) and
|
* their own profile, users change their avatar (`PUT /api/me/avatar`) and
|
||||||
* email, unless their account comes from another instance, and invite
|
* email (`PATCH /api/me`), unless their account comes from another instance, and invite
|
||||||
* someone; their settings, the site's tools and logging out are in the
|
* someone; their settings, the site's tools and logging out are in the
|
||||||
* header's avatar menu.
|
* header's avatar menu.
|
||||||
*/
|
*/
|
||||||
@@ -49,8 +52,6 @@ export function ProfileHeader(
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [emailEditing, setEmailEditing] = useState(false);
|
|
||||||
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
|
|
||||||
const { name, host } = splitHandle(user.username);
|
const { name, host } = splitHandle(user.username);
|
||||||
// A user from another instance changes their avatar and email there.
|
// A user from another instance changes their avatar and email there.
|
||||||
const editable = isOwnProfile && !host;
|
const editable = isOwnProfile && !host;
|
||||||
@@ -83,9 +84,15 @@ export function ProfileHeader(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmailSaved = (email: string) => {
|
const handleEmailSave = async (email: string) => {
|
||||||
|
await expectOk<Wire<PublicUser>>(
|
||||||
|
await authFetch(`${API_URL}/api/me`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email } satisfies UpdateUserRequest),
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (me) updateSession({ user: { ...me, email } });
|
if (me) updateSession({ user: { ...me, email } });
|
||||||
setEmailEditing(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,7 +119,7 @@ export function ProfileHeader(
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-info" ref={infoRef}>
|
<div className="profile-info">
|
||||||
<div className="profile-username-row">
|
<div className="profile-username-row">
|
||||||
<h1 className="profile-username">
|
<h1 className="profile-username">
|
||||||
{name}
|
{name}
|
||||||
@@ -148,34 +155,19 @@ export function ProfileHeader(
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{editable && (
|
{editable && (
|
||||||
emailEditing
|
<InlineEdit
|
||||||
? (
|
value={me?.email ?? ""}
|
||||||
<EmailEditor
|
editable
|
||||||
initialEmail={me?.email ?? ""}
|
label={t`Edit email`}
|
||||||
onCancel={() => setEmailEditing(false)}
|
onSave={handleEmailSave}
|
||||||
onSaved={handleEmailSaved}
|
kind="email"
|
||||||
/>
|
required
|
||||||
)
|
className="profile-email"
|
||||||
: (
|
>
|
||||||
<p
|
<span className="profile-email-text">
|
||||||
className="profile-email-display"
|
{me?.email ?? t`Add email…`}
|
||||||
onClick={() => setEmailEditing(true)}
|
</span>
|
||||||
onKeyDown={(e) => {
|
</InlineEdit>
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setEmailEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
title={t`Edit email`}
|
|
||||||
>
|
|
||||||
{me?.email ?? t`Add email…`}
|
|
||||||
<span className="profile-description-edit-btn" aria-hidden>
|
|
||||||
✎
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
{avatarError && (
|
{avatarError && (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
|
|||||||
@@ -33,12 +33,15 @@ import {
|
|||||||
deserializePlaylist,
|
deserializePlaylist,
|
||||||
deserializePublicUser,
|
deserializePublicUser,
|
||||||
} from "../utils/shared/deserialize.ts";
|
} from "../utils/shared/deserialize.ts";
|
||||||
import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts";
|
import {
|
||||||
|
expectOk,
|
||||||
|
friendlyFetchError,
|
||||||
|
parseAPIResponse,
|
||||||
|
} from "../utils/shared/api.ts";
|
||||||
|
|
||||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||||
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
|
||||||
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
|
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
||||||
import { Avatar } from "../components/shared/Avatar.tsx";
|
import { Avatar } from "../components/shared/Avatar.tsx";
|
||||||
@@ -52,6 +55,7 @@ import { PageError } from "../components/layout/PageError.tsx";
|
|||||||
import { Markdown } from "../components/editor/Markdown.tsx";
|
import { Markdown } from "../components/editor/Markdown.tsx";
|
||||||
import { CommentThread } from "../components/comment/CommentThread.tsx";
|
import { CommentThread } from "../components/comment/CommentThread.tsx";
|
||||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||||
|
import { InlineEdit } from "../components/form/InlineEdit.tsx";
|
||||||
import { userPath } from "../../shared/paths.ts";
|
import { userPath } from "../../shared/paths.ts";
|
||||||
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
|
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
|
||||||
|
|
||||||
@@ -67,9 +71,10 @@ type DumpState =
|
|||||||
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
|
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
|
||||||
* slug, the URL takes the current ones. Its comments, related dumps and
|
* slug, the URL takes the current ones. Its comments, related dumps and
|
||||||
* playlists come from their own endpoints once it is loaded, and the socket's
|
* playlists come from their own endpoints once it is loaded, and the socket's
|
||||||
* events keep them current; a reconnection fetches them and the dump again. A dump deleted, or made private by its poster, while someone else
|
* events keep them current; a reconnection fetches them and the dump again.
|
||||||
* views it gives way to a notice. The poster and moderators can edit the
|
* A dump deleted, or made private by its poster, while someone else views it
|
||||||
* title in place, and get a link to `DumpEdit`.
|
* gives way to a notice. The poster and moderators can edit the title and the
|
||||||
|
* note in place, and get a link to `DumpEdit` for the rest.
|
||||||
*/
|
*/
|
||||||
export function Dump(): React.JSX.Element {
|
export function Dump(): React.JSX.Element {
|
||||||
const { handle, slug } = useParams();
|
const { handle, slug } = useParams();
|
||||||
@@ -129,12 +134,6 @@ export function Dump(): React.JSX.Element {
|
|||||||
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
|
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
|
||||||
const [collections, setCollections] = useState<Playlist[]>([]);
|
const [collections, setCollections] = useState<Playlist[]>([]);
|
||||||
|
|
||||||
const [titleEditing, setTitleEditing] = useState(false);
|
|
||||||
const headerRightRef = useHeightTransition<HTMLDivElement>(titleEditing);
|
|
||||||
const [titleDraft, setTitleDraft] = useState("");
|
|
||||||
const [titleSaving, setTitleSaving] = useState(false);
|
|
||||||
const [titleError, setTitleError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { user, mediaToken, authFetch } = useAuth();
|
const { user, mediaToken, authFetch } = useAuth();
|
||||||
const {
|
const {
|
||||||
voteCounts,
|
voteCounts,
|
||||||
@@ -409,45 +408,23 @@ export function Dump(): React.JSX.Element {
|
|||||||
const canEdit = !!user &&
|
const canEdit = !!user &&
|
||||||
(dump.userId === user.id || can(user, "dump:moderate"));
|
(dump.userId === user.id || can(user, "dump:moderate"));
|
||||||
|
|
||||||
const startTitleEdit = () => {
|
// Saves what the title or the note was changed to in place. The page stays
|
||||||
setTitleDraft(dump.title);
|
// mounted across a slug change, and would keep showing the dump it had.
|
||||||
setTitleError(null);
|
const saveDump = async (changes: UpdateDumpRequest) => {
|
||||||
setTitleEditing(true);
|
const updatedDump = deserializeDump(
|
||||||
};
|
await expectOk<Wire<Dump>>(
|
||||||
|
await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
||||||
const handleTitleSave = async () => {
|
method: "PUT",
|
||||||
const trimmed = titleDraft.trim();
|
body: JSON.stringify(changes),
|
||||||
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
|
}),
|
||||||
|
),
|
||||||
setTitleSaving(true);
|
);
|
||||||
setTitleError(null);
|
setDumpState({ status: "loaded", dump: updatedDump });
|
||||||
try {
|
if (updatedDump.slug !== dump.slug) {
|
||||||
const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
navigate(dumpUrl(updatedDump), {
|
||||||
method: "PUT",
|
replace: true,
|
||||||
body: JSON.stringify(
|
state: { dump: updatedDump },
|
||||||
{ title: trimmed } satisfies UpdateDumpRequest,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
|
|
||||||
if (!apiResponse.success) {
|
|
||||||
setTitleError(apiResponse.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const updatedDump = deserializeDump(apiResponse.data);
|
|
||||||
setTitleEditing(false);
|
|
||||||
// The page stays mounted across the slug change below, and would keep
|
|
||||||
// the old title.
|
|
||||||
setDumpState({ status: "loaded", dump: updatedDump });
|
|
||||||
if (updatedDump.slug !== dump.slug) {
|
|
||||||
navigate(dumpUrl(updatedDump), {
|
|
||||||
replace: true,
|
|
||||||
state: { dump: updatedDump },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setTitleError(friendlyFetchError(err));
|
|
||||||
} finally {
|
|
||||||
setTitleSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -475,79 +452,18 @@ export function Dump(): React.JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dump-header-right" ref={headerRightRef}>
|
<div className="dump-header-right">
|
||||||
{titleEditing
|
<InlineEdit
|
||||||
? (
|
value={dump.title}
|
||||||
<div className="dump-title-editor animate-in">
|
editable={canEdit}
|
||||||
<input
|
label={t`Edit title`}
|
||||||
type="text"
|
onSave={(title) => saveDump({ title })}
|
||||||
className="dump-title-input"
|
required
|
||||||
value={titleDraft}
|
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||||
onChange={(e) => setTitleDraft(e.target.value)}
|
fieldClassName="dump-title"
|
||||||
onKeyDown={(e) => {
|
>
|
||||||
if (e.key === "Enter") {
|
<h1 className="dump-title">{dump.title}</h1>
|
||||||
e.preventDefault();
|
</InlineEdit>
|
||||||
handleTitleSave();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setTitleEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
|
||||||
disabled={titleSaving}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="dump-title-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-primary"
|
|
||||||
onClick={handleTitleSave}
|
|
||||||
disabled={titleSaving || !titleDraft.trim()}
|
|
||||||
>
|
|
||||||
{titleSaving
|
|
||||||
? <Trans>Saving…</Trans>
|
|
||||||
: <Trans>Save</Trans>}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={() => setTitleEditing(false)}
|
|
||||||
disabled={titleSaving}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{titleError && (
|
|
||||||
<p className="dump-title-error">{titleError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div
|
|
||||||
className={`dump-title-view${
|
|
||||||
canEdit ? " dump-title-view--editable" : ""
|
|
||||||
}`}
|
|
||||||
onClick={canEdit ? startTitleEdit : undefined}
|
|
||||||
// Reachable from the keyboard too; not a button, so the
|
|
||||||
// title inside stays a heading.
|
|
||||||
tabIndex={canEdit ? 0 : undefined}
|
|
||||||
title={canEdit ? t`Edit title` : undefined}
|
|
||||||
onKeyDown={canEdit
|
|
||||||
? (e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
startTitleEdit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined}
|
|
||||||
>
|
|
||||||
<h1 className="dump-title">{dump.title}</h1>
|
|
||||||
{canEdit && (
|
|
||||||
<span className="dump-title-edit-btn" aria-hidden>
|
|
||||||
✎
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="dump-op">
|
<div className="dump-op">
|
||||||
<Avatar
|
<Avatar
|
||||||
userId={dump.userId}
|
userId={dump.userId}
|
||||||
@@ -587,8 +503,25 @@ export function Dump(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dump.comment && (
|
{(dump.comment || canEdit) && (
|
||||||
<Markdown className="dump-comment">{dump.comment}</Markdown>
|
<InlineEdit
|
||||||
|
value={dump.comment ?? ""}
|
||||||
|
editable={canEdit}
|
||||||
|
label={t`Edit note`}
|
||||||
|
onSave={(comment) => saveDump({ comment: comment || null })}
|
||||||
|
kind="markdown"
|
||||||
|
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||||
|
placeholder={t`What makes it worth it?`}
|
||||||
|
|
||||||
|
>
|
||||||
|
{dump.comment
|
||||||
|
? <Markdown className="dump-comment">{dump.comment}</Markdown>
|
||||||
|
: (
|
||||||
|
<p className="inline-edit-empty">
|
||||||
|
<Trans>What makes it worth it?</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</InlineEdit>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export interface PlaylistDetailProps {
|
|||||||
* a private one only to its owner. `usePlaylist` loads it, by a former name or
|
* a private one only to its owner. `usePlaylist` loads it, by a former name or
|
||||||
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`,
|
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`,
|
||||||
* can drag its dumps into a new order and remove one, with a short window to
|
* can drag its dumps into a new order and remove one, with a short window to
|
||||||
* undo, and edit or delete it in the editor that opens over its header at
|
* undo, edit its title and description in place, and edit the rest or delete
|
||||||
|
* it in the editor that opens over its header at
|
||||||
* `/@:username/playlists/:slug/edit`. Both paths render the page at the same
|
* `/@:username/playlists/:slug/edit`. Both paths render the page at the same
|
||||||
* place, so opening the editor keeps the playlist loaded. Anyone else there
|
* place, so opening the editor keeps the playlist loaded. Anyone else there
|
||||||
* gets the page, or is asked to sign in first.
|
* gets the page, or is asked to sign in first.
|
||||||
@@ -143,9 +144,10 @@ export function PlaylistDetail(
|
|||||||
<div className="playlist-detail-header-top">
|
<div className="playlist-detail-header-top">
|
||||||
<PlaylistHeader
|
<PlaylistHeader
|
||||||
playlist={playlist}
|
playlist={playlist}
|
||||||
onEdit={canEdit
|
{...(canEdit && {
|
||||||
? () => navigate(playlistUrl(playlist, "edit"))
|
onEdit: () => navigate(playlistUrl(playlist, "edit")),
|
||||||
: undefined}
|
onSaved: applyDetails,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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();
|
expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the owner writes the note in place; emptied, it goes", async () => {
|
||||||
|
dumpRoutes();
|
||||||
|
route(
|
||||||
|
"PUT /api/dumps/:id",
|
||||||
|
() =>
|
||||||
|
wire(makeDump({
|
||||||
|
id: "d1",
|
||||||
|
slug: "a-find",
|
||||||
|
title: "A find",
|
||||||
|
userId: "owner",
|
||||||
|
comment: undefined,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const { user } = openDump(owner);
|
||||||
|
await user.click(await screen.findByText("reasons"));
|
||||||
|
await user.clear(screen.getByDisplayValue("Because **reasons**"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(await screen.findByText("What makes it worth it?"))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ comment: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a visitor sees no note where there is none", async () => {
|
||||||
|
dumpRoutes(makeDump({ slug: "a-find", title: "A find", userId: "owner" }));
|
||||||
|
openDump(other);
|
||||||
|
await screen.findByRole("heading", { name: "A find" });
|
||||||
|
expect(screen.queryByText("What makes it worth it?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("others can't rename or edit it", async () => {
|
test("others can't rename or edit it", async () => {
|
||||||
dumpRoutes();
|
dumpRoutes();
|
||||||
const { user } = openDump(other);
|
const { user } = openDump(other);
|
||||||
@@ -157,7 +186,7 @@ describe("Dump page", () => {
|
|||||||
dumpRoutes();
|
dumpRoutes();
|
||||||
const { user } = openDump(owner);
|
const { user } = openDump(owner);
|
||||||
await screen.findByRole("heading", { name: "A find" });
|
await screen.findByRole("heading", { name: "A find" });
|
||||||
screen.getByTitle("Edit title").focus();
|
screen.getByRole("button", { name: "Edit title" }).focus();
|
||||||
await user.keyboard("{Enter}");
|
await user.keyboard("{Enter}");
|
||||||
expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ describe("PlaylistDetail", () => {
|
|||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
|
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Edit title" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the cover is versioned on the last edit", async () => {
|
test("the cover is versioned on the last edit", async () => {
|
||||||
@@ -140,6 +141,29 @@ describe("PlaylistDetail", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("its owner retitles it in place, which keeps its link", async () => {
|
||||||
|
route(
|
||||||
|
"PUT /api/playlists/:id",
|
||||||
|
() =>
|
||||||
|
wire(makePlaylist({
|
||||||
|
id: "p1",
|
||||||
|
slug: "mix",
|
||||||
|
title: "Remix",
|
||||||
|
userId: "owner",
|
||||||
|
description: "Good **tunes**",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const { user } = open();
|
||||||
|
await user.click(await screen.findByRole("heading", { name: "Mix" }));
|
||||||
|
const input = screen.getByDisplayValue("Mix");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "Remix{Enter}");
|
||||||
|
expect(await screen.findByRole("heading", { name: "Remix" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(currentPath()).toBe("/@owner/playlists/mix");
|
||||||
|
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ title: "Remix" });
|
||||||
|
});
|
||||||
|
|
||||||
test("the editor is a page: /edit opens it, closing goes back to the page", async () => {
|
test("the editor is a page: /edit opens it, closing goes back to the page", async () => {
|
||||||
const { user } = open({ url: "/@owner/playlists/mix/edit" });
|
const { user } = open({ url: "/@owner/playlists/mix/edit" });
|
||||||
expect(await screen.findByRole("textbox", { name: "Description" }))
|
expect(await screen.findByRole("textbox", { name: "Description" }))
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ describe("UserPublicProfile", () => {
|
|||||||
`${API_URL}/api/users/@alice%40home.example/home`,
|
`${API_URL}/api/users/@alice%40home.example/home`,
|
||||||
);
|
);
|
||||||
expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument();
|
expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTitle("Edit email")).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Edit email" })).not
|
||||||
|
.toBeInTheDocument();
|
||||||
expect(screen.queryByText("O.G.")).not.toBeInTheDocument();
|
expect(screen.queryByText("O.G.")).not.toBeInTheDocument();
|
||||||
expect(calls("GET /api/users/:u")[0].path).toBe(
|
expect(calls("GET /api/users/:u")[0].path).toBe(
|
||||||
"/api/users/@alice%40home.example",
|
"/api/users/@alice%40home.example",
|
||||||
@@ -451,10 +452,8 @@ describe("UserPublicProfile", () => {
|
|||||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||||
);
|
);
|
||||||
const { user } = open({ session: annSession });
|
const { user } = open({ session: annSession });
|
||||||
const view = (await screen.findByText("dump")).closest(
|
await screen.findByText("dump");
|
||||||
"[title='Edit description']",
|
screen.getByRole("button", { name: "Edit description" }).focus();
|
||||||
) as HTMLElement;
|
|
||||||
view.focus();
|
|
||||||
await user.keyboard("{Enter}");
|
await user.keyboard("{Enter}");
|
||||||
const box = screen.getByPlaceholderText("Who am I?");
|
const box = screen.getByPlaceholderText("Who am I?");
|
||||||
await user.clear(box);
|
await user.clear(box);
|
||||||
@@ -469,14 +468,11 @@ describe("UserPublicProfile", () => {
|
|||||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||||
);
|
);
|
||||||
const { user } = open({ session: annSession });
|
const { user } = open({ session: annSession });
|
||||||
await user.click(
|
await user.click(await screen.findByText("ann@x.test"));
|
||||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
|
||||||
);
|
|
||||||
const input = screen.getByDisplayValue("ann@x.test");
|
const input = screen.getByDisplayValue("ann@x.test");
|
||||||
await user.clear(input);
|
await user.clear(input);
|
||||||
await user.type(input, "new@x.test{Enter}");
|
await user.type(input, "new@x.test{Enter}");
|
||||||
expect(await screen.findByRole("button", { name: /new@x\.test/ }))
|
expect(await screen.findByText("new@x.test")).toBeInTheDocument();
|
||||||
.toBeInTheDocument();
|
|
||||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
|
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
|
||||||
.toBe("new@x.test");
|
.toBe("new@x.test");
|
||||||
});
|
});
|
||||||
@@ -484,9 +480,7 @@ describe("UserPublicProfile", () => {
|
|||||||
test("a refused email says why", async () => {
|
test("a refused email says why", async () => {
|
||||||
route("PATCH /api/me", () => fail("Email already in use", 409));
|
route("PATCH /api/me", () => fail("Email already in use", 409));
|
||||||
const { user } = open({ session: annSession });
|
const { user } = open({ session: annSession });
|
||||||
await user.click(
|
await user.click(await screen.findByText("ann@x.test"));
|
||||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
|
||||||
);
|
|
||||||
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
|
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
"Email already in use",
|
"Email already in use",
|
||||||
|
|||||||
@@ -172,7 +172,8 @@
|
|||||||
[data-style="brutalist"] .user-menu-trigger,
|
[data-style="brutalist"] .user-menu-trigger,
|
||||||
[data-style="brutalist"] .user-menu-item,
|
[data-style="brutalist"] .user-menu-item,
|
||||||
[data-style="brutalist"] .form-cancel,
|
[data-style="brutalist"] .form-cancel,
|
||||||
[data-style="brutalist"] .form-label-action {
|
[data-style="brutalist"] .form-label-action,
|
||||||
|
[data-style="brutalist"] .inline-edit-mark {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
@@ -184,7 +185,9 @@
|
|||||||
[data-style="brutalist"] .form-cancel:hover:not(:disabled),
|
[data-style="brutalist"] .form-cancel:hover:not(:disabled),
|
||||||
[data-style="brutalist"] .form-cancel:active,
|
[data-style="brutalist"] .form-cancel:active,
|
||||||
[data-style="brutalist"] .form-label-action:hover:not(:disabled),
|
[data-style="brutalist"] .form-label-action:hover:not(:disabled),
|
||||||
[data-style="brutalist"] .form-label-action:active {
|
[data-style="brutalist"] .form-label-action:active,
|
||||||
|
[data-style="brutalist"] .inline-edit-mark:hover:not(:disabled),
|
||||||
|
[data-style="brutalist"] .inline-edit-mark:active {
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,7 +308,7 @@
|
|||||||
|
|
||||||
/* Pressed: the bevel turns inside out and the label moves down and right a
|
/* Pressed: the bevel turns inside out and the label moves down and right a
|
||||||
pixel with the face. */
|
pixel with the face. */
|
||||||
[data-style="geocities"] button:not(.dump-fab, .chat-fab, .btn--ghost, .user-menu-trigger, .user-menu-item):active:not(:disabled, [aria-disabled="true"]),
|
[data-style="geocities"] button:not(.dump-fab, .chat-fab, .btn--ghost, .user-menu-trigger, .user-menu-item, .inline-edit-mark):active:not(:disabled, [aria-disabled="true"]),
|
||||||
[data-style="geocities"] .btn-primary:active:not(:disabled, [aria-disabled="true"]),
|
[data-style="geocities"] .btn-primary:active:not(:disabled, [aria-disabled="true"]),
|
||||||
[data-style="geocities"] .btn-secondary:active:not(:disabled, [aria-disabled="true"]),
|
[data-style="geocities"] .btn-secondary:active:not(:disabled, [aria-disabled="true"]),
|
||||||
[data-style="geocities"] .btn-border:active:not(:disabled, [aria-disabled="true"]),
|
[data-style="geocities"] .btn-border:active:not(:disabled, [aria-disabled="true"]),
|
||||||
@@ -653,13 +653,16 @@
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The crawl stops under the pointer, and for a link that has the focus: the
|
/* The crawl stops under the pointer, and for a link the keyboard has
|
||||||
line then stands at its start, and the browser scrolls it to that link. */
|
focused: the line then stands at its start, and the browser scrolls it to
|
||||||
|
that link. A link the pointer presses takes the focus too, but is left
|
||||||
|
where it is, paused under the pointer: brought back to the start, it would
|
||||||
|
slip away before the button came up, and the click with it. */
|
||||||
[data-style="geocities"] .site-marquee:hover .site-marquee-track {
|
[data-style="geocities"] .site-marquee:hover .site-marquee-track {
|
||||||
animation-play-state: paused;
|
animation-play-state: paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-style="geocities"] .site-marquee:focus-within .site-marquee-track {
|
[data-style="geocities"] .site-marquee:has(:focus-visible) .site-marquee-track {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
.form-field,
|
.form-field,
|
||||||
.profile-role-chip,
|
.profile-role-chip,
|
||||||
.profile-invited-by,
|
.profile-invited-by,
|
||||||
.profile-email-display,
|
.profile-email,
|
||||||
.profile-section-header,
|
.profile-section-header,
|
||||||
.profile-appearance-row,
|
.profile-appearance-row,
|
||||||
.playlist-badge,
|
.playlist-badge,
|
||||||
|
|||||||
Reference in New Issue
Block a user