Compare commits

...

3 Commits

Author SHA1 Message Date
khannurien
eec73e9749 v4: a comment and a chat message are edited in the same field as every text edited in place — each had an editor of its own: a comment's was its reply form, which showed a refused edit as a card and left the focus nowhere once closed, and a chat message's a box of its own with its own buttons and rules. InlineEdit gives its field, InlineEditField, to the components that open it with an action of their own, as these two do with their Edit and ✎: they keep those, a click on someone's message being no request to edit it. The field opens on the text with the caret at its end and as tall as the text, saves on Ctrl+Enter (and on Enter in the chat, where Shift+Enter breaks a line, as in its composer), leaves on Escape, sends nothing for a text left as it was, refuses an empty one, says why a save was refused in one line, and gives the focus back to the button that opened it, unless the caller has put it elsewhere, as the chat does to its composer. CommentForm only posts a comment or a reply now; ChatEditBox keeps only what is the chat's, scrolling itself into the list's view.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m20s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:16:43 +00:00
khannurien
3e03626cc1 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>
2026-10-20 18:08:53 +00:00
khannurien
b92c674726 v4: the devcontainer has Playwright's browsers — they were installed by hand in the home directory, which the rebuild onto Node 26 left without them, and the screenshots of the dev instances stopped working. The Dockerfile installs Firefox and the Chromium headless shell of npm:playwright@1.63.0, with the system libraries they need, in /opt/ms-playwright (PLAYWRIGHT_BROWSERS_PATH), which the workspace and the instances' services share as part of the image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:08:53 +00:00
28 changed files with 1124 additions and 854 deletions

View File

@@ -6,6 +6,15 @@ COPY --from=deno /deno /usr/local/bin/deno
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \ && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Playwright's Firefox and Chromium headless shell, with the system libraries
# they need, for screenshots of the dev instances (`npm:playwright@1.63.0`): a
# script importing another version looks for other builds. They live outside
# the home directory, which a rebuild would otherwise leave without them.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
RUN DENO_DIR=/tmp/deno deno run --no-lock -A npm:playwright@1.63.0 install \
--with-deps firefox chromium-headless-shell \
&& chmod -R a+rX /opt/ms-playwright \
&& rm -rf /tmp/deno /var/lib/apt/lists/*
# The services share Deno's cache through a volume (compose.yaml), which Docker # The services share Deno's cache through a volume (compose.yaml), which Docker
# creates owned as this directory is, so it must exist and be node's. # creates owned as this directory is, so it must exist and be node's.
RUN mkdir -p /home/node/.cache/deno && chown -R node:node /home/node/.cache RUN mkdir -p /home/node/.cache/deno && chown -R node:node /home/node/.cache

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ msgstr ""
"Language-Team: \n" "Language-Team: \n"
"Plural-Forms: \n" "Plural-Forms: \n"
#: src/components/comment/CommentNode.tsx:140 #: src/components/comment/CommentNode.tsx:137
msgid "[deleted]" msgid "[deleted]"
msgstr "[deleted]" msgstr "[deleted]"
@@ -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…"
@@ -466,16 +471,13 @@ msgstr "Can't sign in there"
#: src/components/category/CategoryManager.tsx:241 #: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242 #: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92 #: src/components/comment/CommentForm.tsx:125
#: 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:318
#: 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 +503,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"
@@ -691,9 +693,9 @@ msgstr "Default tab"
#: src/components/category/CategoryManager.tsx:253 #: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118 #: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119 #: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220 #: src/components/chat/ChatMessageItem.tsx:217
#: src/components/comment/CommentNode.tsx:259
#: src/components/comment/CommentNode.tsx:265 #: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36 #: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34 #: src/components/shared/ConfirmModal.tsx:34
msgid "Delete" msgid "Delete"
@@ -721,11 +723,11 @@ 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"
#: src/components/comment/CommentNode.tsx:270 #: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Delete this comment?" msgstr "Delete this comment?"
@@ -733,12 +735,12 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone."
#: src/components/chat/ChatMessageItem.tsx:219 #: src/components/chat/ChatMessageItem.tsx:216
msgid "Delete this message?" 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."
@@ -826,9 +828,9 @@ msgstr "Earlier"
#: src/components/chat/ChatMessageItem.tsx:107 #: src/components/chat/ChatMessageItem.tsx:107
#: 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:250
#: 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 +839,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"
@@ -858,9 +866,9 @@ msgstr "edited"
#. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt)
#. 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:202
#: 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}"
@@ -868,9 +876,9 @@ msgstr "edited {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString()
#. 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:200
#: 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}"
@@ -929,15 +937,13 @@ msgstr "Failed to post"
msgid "Failed to post comment" msgid "Failed to post comment"
msgstr "Failed to post comment" msgstr "Failed to post comment"
#: src/components/comment/CommentNode.tsx:291 #: src/components/comment/CommentNode.tsx:285
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Failed to post reply" 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"
@@ -945,11 +951,7 @@ msgstr "Failed to save"
msgid "Failed to save category" msgid "Failed to save category"
msgstr "Failed to save category" msgstr "Failed to save category"
#: src/components/comment/CommentNode.tsx:218 #: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to save edit"
msgstr "Failed to save edit"
#: src/components/profile/ProfileHeader.tsx:182
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Failed to update avatar" msgstr "Failed to update avatar"
@@ -1064,7 +1066,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 +1097,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 +1167,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 +1180,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…"
@@ -1531,11 +1533,11 @@ msgstr "Playlists"
msgid "Post comment" msgid "Post comment"
msgstr "Post comment" msgstr "Post comment"
#: src/components/comment/CommentNode.tsx:289 #: src/components/comment/CommentNode.tsx:283
msgid "Post reply" msgid "Post reply"
msgstr "Post reply" msgstr "Post reply"
#: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentNode.tsx:284
#: src/components/comment/CommentThread.tsx:100 #: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:752 #: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…" msgid "Posting…"
@@ -1548,9 +1550,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 +1561,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 +1595,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"
@@ -1633,7 +1635,7 @@ msgstr "Replace file"
#: src/components/chat/ChatMessageItem.tsx:97 #: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98 #: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244 #: src/components/comment/CommentNode.tsx:240
msgid "Reply" msgid "Reply"
msgstr "Reply" msgstr "Reply"
@@ -1680,7 +1682,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"
@@ -1690,22 +1692,15 @@ msgid "Role"
msgstr "Role" msgstr "Role"
#: src/components/category/CategoryManager.tsx:232 #: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/form/InlineEdit.tsx:310
#: src/components/comment/CommentNode.tsx:216
#: 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/form/InlineEdit.tsx:307
#: 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 +1810,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"
@@ -1875,11 +1870,11 @@ msgstr "The month on {site}, {range}"
msgid "The new order couldn't be saved. Try again." msgid "The new order couldn't be saved. Try again."
msgstr "The new order couldn't be saved. Try again." msgstr "The new order couldn't be saved. Try again."
#: src/components/comment/CommentNode.tsx:282 #: src/components/comment/CommentNode.tsx:276
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 +2028,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 +2131,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?"
@@ -2158,7 +2155,7 @@ msgstr "Why & where"
msgid "Why?" msgid "Why?"
msgstr "Why?" msgstr "Why?"
#: src/components/comment/CommentNode.tsx:293 #: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…" msgid "Write a reply…"
msgstr "Write a reply…" msgstr "Write a reply…"

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ msgstr ""
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
#: src/components/comment/CommentNode.tsx:140 #: src/components/comment/CommentNode.tsx:137
msgid "[deleted]" msgid "[deleted]"
msgstr "[supprimé]" msgstr "[supprimé]"
@@ -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…"
@@ -466,16 +471,13 @@ msgstr "Connexion impossible"
#: src/components/category/CategoryManager.tsx:241 #: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242 #: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92 #: src/components/comment/CommentForm.tsx:125
#: 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:318
#: 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 +503,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"
@@ -691,9 +693,9 @@ msgstr "Onglet par défaut"
#: src/components/category/CategoryManager.tsx:253 #: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118 #: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119 #: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220 #: src/components/chat/ChatMessageItem.tsx:217
#: src/components/comment/CommentNode.tsx:259
#: src/components/comment/CommentNode.tsx:265 #: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36 #: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34 #: src/components/shared/ConfirmModal.tsx:34
msgid "Delete" msgid "Delete"
@@ -721,11 +723,11 @@ 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"
#: src/components/comment/CommentNode.tsx:270 #: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?" msgstr "Supprimer ce commentaire ?"
@@ -733,12 +735,12 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible." msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/components/chat/ChatMessageItem.tsx:219 #: src/components/chat/ChatMessageItem.tsx:216
msgid "Delete this message?" 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."
@@ -826,9 +828,9 @@ msgstr "Plus tôt"
#: src/components/chat/ChatMessageItem.tsx:107 #: src/components/chat/ChatMessageItem.tsx:107
#: 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:250
#: 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 +839,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"
@@ -858,9 +866,9 @@ msgstr "modifié"
#. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt)
#. 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:202
#: 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}"
@@ -868,9 +876,9 @@ msgstr "modifié {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString()
#. 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:200
#: 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}"
@@ -929,15 +937,13 @@ msgstr "Publication échouée"
msgid "Failed to post comment" msgid "Failed to post comment"
msgstr "Impossible de publier le commentaire" msgstr "Impossible de publier le commentaire"
#: src/components/comment/CommentNode.tsx:291 #: src/components/comment/CommentNode.tsx:285
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Impossible de publier la réponse" 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é"
@@ -945,11 +951,7 @@ msgstr "Enregistrement échoué"
msgid "Failed to save category" msgid "Failed to save category"
msgstr "Échec de l’enregistrement de la catégorie" msgstr "Échec de l’enregistrement de la catégorie"
#: src/components/comment/CommentNode.tsx:218 #: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
#: src/components/profile/ProfileHeader.tsx:182
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 +1066,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 +1097,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 +1167,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 +1180,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…"
@@ -1531,11 +1533,11 @@ msgstr "Collections"
msgid "Post comment" msgid "Post comment"
msgstr "Publier le commentaire" msgstr "Publier le commentaire"
#: src/components/comment/CommentNode.tsx:289 #: src/components/comment/CommentNode.tsx:283
msgid "Post reply" msgid "Post reply"
msgstr "Publier la réponse" msgstr "Publier la réponse"
#: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentNode.tsx:284
#: src/components/comment/CommentThread.tsx:100 #: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:752 #: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…" msgid "Posting…"
@@ -1548,9 +1550,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 +1561,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 +1595,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"
@@ -1633,7 +1635,7 @@ msgstr "Remplacer le fichier"
#: src/components/chat/ChatMessageItem.tsx:97 #: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98 #: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244 #: src/components/comment/CommentNode.tsx:240
msgid "Reply" msgid "Reply"
msgstr "Répondre" msgstr "Répondre"
@@ -1680,7 +1682,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"
@@ -1690,22 +1692,15 @@ msgid "Role"
msgstr "Rôle" msgstr "Rôle"
#: src/components/category/CategoryManager.tsx:232 #: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/form/InlineEdit.tsx:310
#: src/components/comment/CommentNode.tsx:216
#: 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/form/InlineEdit.tsx:307
#: 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 +1810,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"
@@ -1875,11 +1870,11 @@ msgstr "Le mois sur {site}, {range}"
msgid "The new order couldn't be saved. Try again." msgid "The new order couldn't be saved. Try again."
msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez." msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
#: src/components/comment/CommentNode.tsx:282 #: src/components/comment/CommentNode.tsx:276
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 +2028,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 +2131,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 ?"
@@ -2158,7 +2155,7 @@ msgstr "Pourquoi & où"
msgid "Why?" msgid "Why?"
msgstr "Pourquoi ?" msgstr "Pourquoi ?"
#: src/components/comment/CommentNode.tsx:293 #: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…" msgid "Write a reply…"
msgstr "Écrire une réponse…" msgstr "Écrire une réponse…"

View File

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

View File

@@ -5,39 +5,33 @@
* @module * @module
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
import { Trans } from "@lingui/react/macro"; import { InlineEditField } from "../form/InlineEdit.tsx";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; import { COUNT_FROM } from "../../utils/chat/message.ts";
import { COUNT_FROM, sendable } from "../../utils/chat/message.ts";
import { VALIDATION } from "../../../shared/validation.ts"; import { VALIDATION } from "../../../shared/validation.ts";
/** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */ /** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */
export interface ChatEditBoxProps { export interface ChatEditBoxProps {
initial: string; initial: string;
/** /** Receives the trimmed text, only when it changed and can be sent. */
* Receives the trimmed text, only when it can be sent. It may be unchanged:
* the caller decides whether to send an edit.
*/
onSave: (body: string) => void; onSave: (body: string) => void;
/** Also called on Escape. */ /** Called once the text is saved, or left, Escape included. */
onCancel: () => void; onClose: () => void;
} }
/** /**
* Renders an editor holding `initial`, with Save and Cancel. Enter saves and * Renders the field every text edited in place gets, `InlineEditField`, with
* Shift+Enter starts a new line, as in the composer. * Enter saving and Shift+Enter starting a new line, as in the composer. It
* scrolls itself into the list's view.
*/ */
export function ChatEditBox( export function ChatEditBox(
{ initial, onSave, onCancel }: ChatEditBoxProps, { initial, onSave, onClose }: ChatEditBoxProps,
): React.JSX.Element { ): React.JSX.Element {
const [draft, setDraft] = useState(initial);
const editorRef = useRef<TextEditorHandle>(null);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
// The message is still easing to its new height, so a scroll on focus would // The message is still easing to its new height, so a scroll now would land
// land short. Scroll once the transition is over, Save button included. // short. Scroll once the transition is over, Save button included.
useEffect(() => { useEffect(() => {
editorRef.current?.focus({ atEnd: true, preventScroll: true });
const box = boxRef.current; const box = boxRef.current;
if (!box) return; if (!box) return;
const settle = parseFloat( const settle = parseFloat(
@@ -50,48 +44,19 @@ export function ChatEditBox(
return () => clearTimeout(id); return () => clearTimeout(id);
}, []); }, []);
const save = () => {
if (!sendable(draft)) return;
onSave(draft.trim());
};
return ( return (
<div className="chat-edit animate-in" ref={boxRef}> <div className="chat-edit" ref={boxRef}>
<TextEditor <InlineEditField
ref={editorRef} value={initial}
value={draft} onSave={onSave}
onChange={setDraft} onClose={onClose}
rows={2} kind="markdown"
autoResize required
maxLength={VALIDATION.CHAT_MESSAGE_MAX} maxLength={VALIDATION.CHAT_MESSAGE_MAX}
countFrom={COUNT_FROM} countFrom={COUNT_FROM}
className="comment-reply-textarea chat-input" fieldClassName="chat-input"
onSubmit={save} enterSaves
onCancel={onCancel}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
}}
/> />
<div className="chat-edit-actions">
<button
type="button"
className="btn-primary btn-sm"
onClick={save}
disabled={!sendable(draft)}
>
<Trans>Save</Trans>
</button>
<button
type="button"
className="btn-secondary btn-sm"
onClick={onCancel}
>
<Trans>Cancel</Trans>
</button>
</div>
</div> </div>
); );
} }

View File

@@ -194,11 +194,8 @@ export function ChatMessageItem(
? ( ? (
<ChatEditBox <ChatEditBox
initial={message.body} initial={message.body}
onSave={(body) => { onSave={(body) => onEdit(message.id, body)}
if (body !== message.body) onEdit(message.id, body); onClose={onEndEdit}
onEndEdit();
}}
onCancel={onEndEdit}
/> />
) )
: ( : (

View File

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

View File

@@ -1,6 +1,6 @@
/** /**
* Renders the form of a dump's comment thread that posts a comment, a reply, * Renders the form of a dump's comment thread that posts a comment or a
* or an edit. * reply. An edit opens `InlineEditField` in the comment's place.
* *
* @module * @module
*/ */
@@ -15,7 +15,7 @@ import { useApiForm } from "../../hooks/form/useApiForm.ts";
/** /**
* Passed to {@link CommentForm} by `CommentThread`, for a new comment, and by * Passed to {@link CommentForm} by `CommentThread`, for a new comment, and by
* `CommentNode`, for a reply or an edit. * `CommentNode`, for a reply.
*/ */
export interface CommentFormProps { export interface CommentFormProps {
/** /**
@@ -28,7 +28,6 @@ export interface CommentFormProps {
pendingLabel: React.ReactNode; pendingLabel: React.ReactNode;
errorTitle: string; errorTitle: string;
placeholder?: string; placeholder?: string;
defaultValue?: string;
/** Without it, the form has no Cancel button. */ /** Without it, the form has no Cancel button. */
onCancel?: () => void; onCancel?: () => void;
/** /**
@@ -39,7 +38,7 @@ export interface CommentFormProps {
editorRef?: React.Ref<TextEditorHandle>; editorRef?: React.Ref<TextEditorHandle>;
/** /**
* `top` puts `leading` beside the editor, for the new-comment form. `inline` * `top` puts `leading` beside the editor, for the new-comment form. `inline`
* is for replies and edits, inside a comment. * is for replies, inside a comment.
*/ */
layout?: "inline" | "top"; layout?: "inline" | "top";
leading?: React.ReactNode; leading?: React.ReactNode;
@@ -57,7 +56,6 @@ export function CommentForm({
pendingLabel, pendingLabel,
errorTitle, errorTitle,
placeholder, placeholder,
defaultValue = "",
onCancel, onCancel,
cancelWhenEmpty, cancelWhenEmpty,
editorRef, editorRef,
@@ -66,7 +64,7 @@ export function CommentForm({
className, className,
}: CommentFormProps): React.JSX.Element { }: CommentFormProps): React.JSX.Element {
const form = useApiForm<{ body: string }>({ const form = useApiForm<{ body: string }>({
defaultValues: { body: defaultValue }, defaultValues: { body: "" },
}); });
const body = form.watch("body"); const body = form.watch("body");
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX; const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
@@ -91,8 +89,8 @@ export function CommentForm({
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
onSubmit={() => void submit()} onSubmit={() => void submit()}
// Escape closes a reply or an edit. The new-comment form has // Escape closes a reply. The new-comment form has nothing to
// nothing to close. // close.
onCancel={onCancel && !cancelWhenEmpty onCancel={onCancel && !cancelWhenEmpty
? () => { ? () => {
form.reset({ body: "" }); form.reset({ body: "" });

View File

@@ -30,6 +30,8 @@ import { useWS } from "../../hooks/ws/useWS.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { expectOk } from "../../utils/shared/api.ts"; import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx"; import { CommentForm } from "./CommentForm.tsx";
import { InlineEditField } from "../form/InlineEdit.tsx";
import { VALIDATION } from "../../../shared/validation.ts";
import { userPath } from "../../../shared/paths.ts"; import { userPath } from "../../../shared/paths.ts";
/** /**
@@ -75,7 +77,7 @@ export function CommentNode({
); );
const replyEditorRef = useRef<TextEditorHandle>(null); const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null); const editButtonRef = useRef<HTMLButtonElement>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS(); const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
const { authFetch } = useAuth(); const { authFetch } = useAuth();
@@ -105,18 +107,13 @@ export function CommentNode({
} }
} }
// Not called with the text unchanged, which would only mark it "edited".
async function handleEditSave(body: string) { async function handleEditSave(body: string) {
// Saving it unchanged would only mark it "edited".
if (body === comment.body) {
setEditOpen(false);
return;
}
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, { const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body } satisfies UpdateCommentRequest), body: JSON.stringify({ body } satisfies UpdateCommentRequest),
}); });
onCommentUpdated(deserializeComment(await expectOk<Wire<Comment>>(res))); onCommentUpdated(deserializeComment(await expectOk<Wire<Comment>>(res)));
setEditOpen(false);
} }
const canModerate = can(currentUser, "comment:moderate"); const canModerate = can(currentUser, "comment:moderate");
@@ -209,15 +206,14 @@ export function CommentNode({
</div> </div>
{editOpen {editOpen
? ( ? (
<CommentForm <InlineEditField
editorRef={editEditorRef} value={comment.body}
defaultValue={comment.body} onSave={handleEditSave}
onSubmit={handleEditSave} onClose={() => setEditOpen(false)}
submitLabel={<Trans>Save</Trans>} returnFocusTo={editButtonRef}
pendingLabel={<Trans>Saving…</Trans>} kind="markdown"
errorTitle={t`Failed to save edit`} required
className="animate-in" maxLength={VALIDATION.COMMENT_BODY_MAX}
onCancel={() => setEditOpen(false)}
/> />
) )
: <Markdown className="comment-body">{comment.body}</Markdown>} : <Markdown className="comment-body">{comment.body}</Markdown>}
@@ -246,12 +242,10 @@ export function CommentNode({
)} )}
{canEdit && !editOpen && ( {canEdit && !editOpen && (
<button <button
ref={editButtonRef}
type="button" type="button"
className="comment-action-btn" className="comment-action-btn"
onClick={() => { onClick={() => setEditOpen(true)}
setEditOpen(true);
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
> >
<Trans>Edit</Trans> <Trans>Edit</Trans>
</button> </button>

View File

@@ -0,0 +1,340 @@
/**
* Renders the editing of a text in place, with no page or form of its own.
* {@link InlineEdit} shows the text and opens the field over it: a dump's
* title and note, a playlist's title and description, a profile's description
* and email. {@link InlineEditField} is that field alone, for a text that
* opens it with an action of its own: a comment's Edit, a chat message's ✎.
*
* @module
*/
import { type ReactNode, 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 the field: see {@link InlineEditFieldProps}. */
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 rootRef = useHeightTransition<HTMLDivElement>(editing);
const markRef = useRef<HTMLButtonElement>(null);
if (!editable) return <>{children}</>;
return (
<div
ref={rootRef}
className={`inline-edit inline-edit--${
kind === "markdown" ? "block" : "line"
}${editing ? " inline-edit--editing" : ""}${
className ? ` ${className}` : ""
}`}
>
{editing
? (
<InlineEditField
value={value}
onSave={onSave}
onClose={() => setEditing(false)}
returnFocusTo={markRef}
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>
);
}
/**
* Passed to {@link InlineEditField} by {@link InlineEdit}, and by the
* components that open it with an action of their own, `CommentNode` and
* `ChatMessageItem`, in place of the text while it is edited.
*/
export interface InlineEditFieldProps extends
Pick<
InlineEditProps,
"value" | "kind" | "required" | "maxLength" | "placeholder"
> {
/**
* Saves the trimmed text. The field closes once it is done, and stays open
* under the error it throws. A text left as it was isn't passed to it.
*/
onSave: (value: string) => Promise<void> | void;
/** Called once the text is saved, or left: the field is to unmount. */
onClose: () => void;
/** Names a one-line field, such as "Edit title". */
label?: string;
/**
* Added to the field: for a one-line one, the class of the text it edits,
* such as `dump-title`, so that it takes on its type in every style.
*/
fieldClassName?: string;
/**
* In the Markdown editor, saves on Enter, a line breaking on Shift+Enter, as
* the chat's composer does. Ctrl/Cmd+Enter saves in either case.
*/
enterSaves?: boolean;
/** Where the Markdown editor's counter starts, near `maxLength`. */
countFrom?: number;
/**
* Takes the focus once the field is gone, if it had it and no one has
* taken it since: the button that opened it, back in its place.
*/
returnFocusTo?: React.RefObject<HTMLElement | null>;
}
/**
* Renders the field holding `value`, focused with the caret at its end, and
* Save and Cancel under it. Enter saves a line, Ctrl/Cmd+Enter a Markdown
* text, Escape leaves. Saving is refused while the text is empty and
* `required`, or longer than `maxLength`. It is mounted afresh on each
* opening, so that it starts from `value`.
*/
export function InlineEditField(
{
value,
onSave,
onClose,
kind = "text",
required,
maxLength,
placeholder,
fieldClassName,
label,
enterSaves,
countFrom,
returnFocusTo,
}: InlineEditFieldProps,
): 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);
// Runs once the field is gone and the button that opened it is back. The
// focus fell to the page with the field that had it, unless the caller has
// put it somewhere already.
useEffect(() => () => {
const active = document.activeElement;
if (!active || active === document.body) {
returnFocusTo?.current?.focus({ preventScroll: true });
}
}, [returnFocusTo]);
// 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 save = () => {
if (!tooLong && !blank) void onSubmit();
};
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${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
value={field.value}
onChange={field.onChange}
onSubmit={save}
onCancel={cancel}
onKeyDown={enterSaves
? (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
}
: undefined}
placeholder={placeholder}
// As tall as the text it opens on, growing as it is typed.
rows={1}
autoResize
maxLength={maxLength}
countFrom={countFrom}
disabled={submitting}
/>
)}
/>
)
: (
<input
{...registration}
ref={(el) => {
registerRef(el);
inputRef.current = el;
}}
type={kind}
className={`inline-edit-input${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
aria-label={label}
placeholder={placeholder}
maxLength={maxLength}
disabled={submitting}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
/>
)}
<div className="inline-edit-actions">
<SubmitButton
className="btn-primary btn-sm"
pendingLabel={<Trans>Saving…</Trans>}
disabled={tooLong || blank}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary btn-sm"
onClick={cancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<InlineEditError />
</div>
</form>
</FormProvider>
);
}
/**
* Renders why the save failed, as one line beside the buttons: an
* `ErrorCard` would outweigh the field it is about.
*/
function InlineEditError(): React.JSX.Element | null {
const { errors } = useFormState();
const message = errors.root?.message;
if (!message) return null;
return (
<p className="inline-edit-error" role="alert">
{message}
</p>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -191,6 +191,21 @@ describe("CommentThread", () => {
expect(calls("PATCH /api/comments/:id")).toHaveLength(0); expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
}); });
test("a refused edit stays open and says why; Escape goes back to Edit", async () => {
route("PATCH /api/comments/:id", () => fail("Too spicy", 400));
const user = setup([makeComment({ id: "a", userId: "me", body: "old" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.type(
screen.getByDisplayValue("old"),
"er{Control>}{Enter}{/Control}",
);
expect(await screen.findByRole("alert")).toHaveTextContent("Too spicy");
expect(screen.getByDisplayValue("older")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.getByText("old")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit" })).toHaveFocus();
});
test("only the author or a moderator can edit and delete", () => { test("only the author or a moderator can edit and delete", () => {
const comments = [ const comments = [
makeComment({ id: "mine", userId: "me", body: "mine" }), makeComment({ id: "mine", userId: "me", body: "mine" }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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