Compare commits
3 Commits
7d0a0e7534
...
eec73e9749
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec73e9749 | ||
|
|
3e03626cc1 | ||
|
|
b92c674726 |
@@ -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
141
locales/en.po
141
locales/en.po
@@ -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
141
locales/fr.po
141
locales/fr.po
@@ -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…"
|
||||||
|
|
||||||
|
|||||||
318
src/App.css
318
src/App.css
@@ -223,67 +223,151 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-view {
|
/* ── Inline edit ── */
|
||||||
|
/* Text edited where it stands (InlineEdit.tsx). Hovered, the text sits on a
|
||||||
|
faint plate with a ✎ after it; opened, the field takes the plate's place
|
||||||
|
and the text's type, so that nothing jumps but the buttons that come under
|
||||||
|
it. The plate spills past the text, drawn under it by a pseudo-element: the
|
||||||
|
text's own box keeps its size, which a row measuring it (a playlist's title
|
||||||
|
beside its buttons) depends on. */
|
||||||
|
.inline-edit {
|
||||||
|
/* Tinted from the text, as it sits on a card or on the page alike. */
|
||||||
|
--inline-edit-plate: color-mix(in srgb, var(--color-text) 7%, transparent);
|
||||||
|
--inline-edit-pad-y: 0.1rem;
|
||||||
|
--inline-edit-pad-x: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit--block {
|
||||||
|
--inline-edit-pad-y: 0.45rem;
|
||||||
|
--inline-edit-pad-x: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.inline-edit-view {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
max-width: 100%;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-view::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(-1 * var(--inline-edit-pad-y))
|
||||||
|
calc(-1 * var(--inline-edit-pad-x));
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit--line .inline-edit-view {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4em;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
max-width: 100%;
|
|
||||||
/* Padding gives the hover box breathing room (incl. right of the pencil);
|
|
||||||
the matching negative margin keeps the title's visual position unchanged. */
|
|
||||||
padding: 0.1rem 0.45rem;
|
|
||||||
margin: -0.1rem -0.45rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-view--editable {
|
/* Room on the right for the ✎, which sits in the corner. */
|
||||||
cursor: pointer;
|
.inline-edit--block .inline-edit-view {
|
||||||
|
display: block;
|
||||||
|
padding-right: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-view--editable:hover {
|
.inline-edit-view:hover::before {
|
||||||
background: var(--color-surface);
|
background: var(--inline-edit-plate);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-edit-btn {
|
.inline-edit-mark {
|
||||||
font-size: var(--text-sm);
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding: 0.15em;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity var(--dur-fast);
|
transition: opacity var(--dur-fast);
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-view--editable:hover .dump-title-edit-btn,
|
.inline-edit--block .inline-edit-mark {
|
||||||
.dump-title-view--editable:focus-visible .dump-title-edit-btn {
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-view:hover .inline-edit-mark,
|
||||||
|
.inline-edit-mark:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-editor {
|
/* No pointer to hover with: the mark stays, faintly, to say it can be
|
||||||
|
tapped. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.inline-edit-mark {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What an empty text says to the one who may fill it. */
|
||||||
|
.inline-edit-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typed as the text it replaces: the type is inherited at no specificity, so
|
||||||
|
that the class of that text (`fieldClassName`) and a style's rules for it
|
||||||
|
both set it over this. */
|
||||||
|
:where(.inline-edit-input) {
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: calc(100% + 2 * var(--inline-edit-pad-x));
|
||||||
|
min-width: 0;
|
||||||
|
padding: calc(var(--inline-edit-pad-y) - var(--border-width))
|
||||||
|
calc(var(--inline-edit-pad-x) - var(--border-width));
|
||||||
|
margin: calc(-1 * var(--inline-edit-pad-y))
|
||||||
|
calc(-1 * var(--inline-edit-pad-x));
|
||||||
|
border: var(--border-width) solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--inline-edit-plate);
|
||||||
|
transition: border-color var(--dur-base), box-shadow var(--dur-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* As the Markdown editor's box takes the focus. */
|
||||||
|
.inline-edit-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px
|
||||||
|
color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared chrome for inline-edit inputs (the editors that live outside `.form`).
|
.inline-edit-error {
|
||||||
Each rule below adds only its own size / weight / width deltas. */
|
margin: 0;
|
||||||
.dump-title-input,
|
font-size: var(--text-sm);
|
||||||
.profile-email-input,
|
color: var(--color-danger);
|
||||||
.username-field-input {
|
|
||||||
border: var(--border-width) solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: var(--color-surface-sunken);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color var(--dur-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dump-title-input:focus,
|
|
||||||
.profile-email-input:focus,
|
|
||||||
.username-field-input:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A name and its "Rename" button, the hint and errors below. */
|
/* A name and its "Rename" button, the hint and errors below. */
|
||||||
@@ -304,28 +388,18 @@
|
|||||||
flex: 1 1 10rem;
|
flex: 1 1 10rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-sunken);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: inherit;
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--dur-slow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-title-input {
|
.username-field-input:focus {
|
||||||
flex: 1;
|
border-color: var(--color-accent);
|
||||||
min-width: 12rem;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Save and Cancel wrap together, under the input on a phone. */
|
|
||||||
.dump-title-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dump-title-error {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On the narrowest viewports the fixed-width action column squeezes the title
|
/* On the narrowest viewports the fixed-width action column squeezes the title
|
||||||
@@ -2357,23 +2431,9 @@ body.has-player .chat-fab {
|
|||||||
/* No margins of its own: the page's gap already sets it apart. */
|
/* No margins of its own: the page's gap already sets it apart. */
|
||||||
.profile-description {
|
.profile-description {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-view {
|
|
||||||
position: relative;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-md);
|
font-size: var(--text-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-description-view--editable {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-view--editable:hover {
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-text {
|
.profile-description-text {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
@@ -2381,80 +2441,16 @@ body.has-player .chat-fab {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-description-empty {
|
.profile-email {
|
||||||
color: var(--color-muted);
|
margin: 0.1rem 0 0.4rem;
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-edit-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.55rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-muted);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--dur-fast);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-view--editable:hover .profile-description-edit-btn,
|
|
||||||
.profile-description-view--editable:focus-visible .profile-description-edit-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-email-display {
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin: 0.1rem 0 0.4rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.1rem 0.2rem;
|
|
||||||
margin-left: -0.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-email-display:hover {
|
/* An address is typed a size up from the line it shows on. */
|
||||||
background: var(--color-surface);
|
.profile-email .inline-edit-input {
|
||||||
}
|
max-width: 22rem;
|
||||||
|
|
||||||
.profile-email-display:hover .profile-description-edit-btn,
|
|
||||||
.profile-email-display:focus-visible .profile-description-edit-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-email-editor {
|
|
||||||
margin: 0.2rem 0 0.4rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-email-input {
|
|
||||||
width: min(260px, 100%);
|
|
||||||
padding: 0.35rem 0.6rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-email-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-description-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-invited-by {
|
.profile-invited-by {
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: "" });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
340
src/components/form/InlineEdit.tsx
Normal file
340
src/components/form/InlineEdit.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,13 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts";
|
import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts";
|
||||||
import type { OnlineUser } from "../../../shared/ws.ts";
|
import type { OnlineUser } from "../../../shared/ws.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
@@ -24,11 +30,16 @@ function onMotionChange(callback: () => void): () => void {
|
|||||||
return () => query.removeEventListener("change", callback);
|
return () => query.removeEventListener("change", callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the crawl began its current loop. It is kept out of the component,
|
// Where the crawl had got to, and what its loop showed, when the marquee was
|
||||||
// which every page mounts anew with its header: the next page's marquee
|
// last on screen. They are kept out of the component, which every page mounts
|
||||||
// carries on from where the last one's had got to, rather than from the
|
// anew with its header: the next page's marquee carries on from that point of
|
||||||
// start.
|
// that loop, rather than from the start. A crawl that was running has gone on
|
||||||
let loopStart: number | undefined;
|
// since; one paused under the pointer, which has just clicked a link in it,
|
||||||
|
// hasn't.
|
||||||
|
let lastPosition:
|
||||||
|
| { elapsed: number; at: number; running: boolean }
|
||||||
|
| undefined;
|
||||||
|
let lastLoop: Loop | null = null;
|
||||||
|
|
||||||
/** What one loop of the crawl shows, from its start to its end. */
|
/** What one loop of the crawl shows, from its start to its end. */
|
||||||
interface Loop {
|
interface Loop {
|
||||||
@@ -61,8 +72,12 @@ function FeedMarquee(): React.JSX.Element {
|
|||||||
onMotionChange,
|
onMotionChange,
|
||||||
() => matchMedia(REDUCED_MOTION).matches,
|
() => matchMedia(REDUCED_MOTION).matches,
|
||||||
);
|
);
|
||||||
const [loop, setLoop] = useState<Loop | null>(null);
|
// A marquee with no feed yet is a first one, or follows a sign-out.
|
||||||
|
const [loop, setLoop] = useState<Loop | null>(() => feed && lastLoop);
|
||||||
if (!loop && feed) setLoop({ feed, online, turn: 0 });
|
if (!loop && feed) setLoop({ feed, online, turn: 0 });
|
||||||
|
useEffect(() => {
|
||||||
|
lastLoop = loop;
|
||||||
|
}, [loop]);
|
||||||
const shown = still ? feed && { feed, online } : loop;
|
const shown = still ? feed && { feed, online } : loop;
|
||||||
|
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -71,16 +86,32 @@ function FeedMarquee(): React.JSX.Element {
|
|||||||
const length = track?.firstElementChild?.getBoundingClientRect().width;
|
const length = track?.firstElementChild?.getBoundingClientRect().width;
|
||||||
if (!track || !length) return;
|
if (!track || !length) return;
|
||||||
track.style.setProperty("--marquee-length", String(Math.round(length)));
|
track.style.setProperty("--marquee-length", String(Math.round(length)));
|
||||||
loopStart ??= performance.now();
|
const last = lastPosition;
|
||||||
|
lastPosition = undefined;
|
||||||
|
const elapsed = !last
|
||||||
|
? 0
|
||||||
|
: last.elapsed + (last.running ? performance.now() - last.at : 0);
|
||||||
// A negative delay starts the animation that far in.
|
// A negative delay starts the animation that far in.
|
||||||
track.style.animationDelay = `${(loopStart - performance.now()) / 1000}s`;
|
track.style.animationDelay = `${-elapsed / 1000}s`;
|
||||||
|
return () => {
|
||||||
|
// The track's own animation: the focus can have stopped it.
|
||||||
|
const animation = track.getAnimations()[0];
|
||||||
|
const timing = animation?.effect?.getComputedTiming();
|
||||||
|
if (!timing?.progress || typeof timing.duration !== "number") return;
|
||||||
|
lastPosition = {
|
||||||
|
elapsed: timing.progress * timing.duration,
|
||||||
|
at: performance.now(),
|
||||||
|
running: animation.playState === "running",
|
||||||
|
};
|
||||||
|
};
|
||||||
}, [loop?.turn, still]);
|
}, [loop?.turn, still]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="site-marquee"
|
className="site-marquee"
|
||||||
// A focused link stops the crawl, and the browser scrolls the line to
|
// A link focused from the keyboard stops the crawl, and the browser
|
||||||
// show it: the line goes back to its start once the focus has left.
|
// scrolls the line to show it: the line goes back to its start once the
|
||||||
|
// focus has left.
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
e.currentTarget.scrollLeft = 0;
|
e.currentTarget.scrollLeft = 0;
|
||||||
@@ -95,7 +126,6 @@ function FeedMarquee(): React.JSX.Element {
|
|||||||
onAnimationIteration={(e) => {
|
onAnimationIteration={(e) => {
|
||||||
// Not a child's animation ending a round of its own.
|
// Not a child's animation ending a round of its own.
|
||||||
if (e.target !== e.currentTarget || !feed || !loop) return;
|
if (e.target !== e.currentTarget || !feed || !loop) return;
|
||||||
loopStart = performance.now();
|
|
||||||
setLoop({ feed, online, turn: loop.turn + 1 });
|
setLoop({ feed, online, turn: loop.turn + 1 });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,12 +8,22 @@
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import type { PlaylistWithDumps } from "../../../shared/playlist.ts";
|
import { API_URL } from "../../config/api.ts";
|
||||||
|
import { VALIDATION } from "../../../shared/validation.ts";
|
||||||
|
import type { Wire } from "../../../shared/api.ts";
|
||||||
|
import type {
|
||||||
|
Playlist,
|
||||||
|
PlaylistWithDumps,
|
||||||
|
UpdatePlaylistRequest,
|
||||||
|
} from "../../../shared/playlist.ts";
|
||||||
|
import { expectOk } from "../../utils/shared/api.ts";
|
||||||
|
import { deserializePlaylist } from "../../utils/shared/deserialize.ts";
|
||||||
import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts";
|
import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts";
|
||||||
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
||||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
import { Markdown } from "../editor/Markdown.tsx";
|
import { Markdown } from "../editor/Markdown.tsx";
|
||||||
|
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||||
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
|
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
|
||||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||||
import { CategoryChips } from "../category/CategoryChips.tsx";
|
import { CategoryChips } from "../category/CategoryChips.tsx";
|
||||||
@@ -22,18 +32,38 @@ import { userPath } from "../../../shared/paths.ts";
|
|||||||
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
|
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
|
||||||
export interface PlaylistHeaderProps {
|
export interface PlaylistHeaderProps {
|
||||||
playlist: PlaylistWithDumps;
|
playlist: PlaylistWithDumps;
|
||||||
/** Given to those who may edit it, for whom it adds an "Edit" button. */
|
/**
|
||||||
|
* Given to those who may edit it, with `onSaved`: it adds an "Edit" button,
|
||||||
|
* which opens `PlaylistEditForm`.
|
||||||
|
*/
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
/**
|
||||||
|
* Called with the playlist as the server has it once its title or
|
||||||
|
* description is edited in place, which it lets its editors do.
|
||||||
|
*/
|
||||||
|
onSaved?: (playlist: Playlist) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the playlist's details, with a follow button for anyone but its
|
* Renders the playlist's details, with a follow button for anyone but its
|
||||||
* owner. `PlaylistEditForm` takes its place while it is edited.
|
* owner. Its editors change the title and the description in place, saved to
|
||||||
|
* `PUT /api/playlists/:playlistId`, and the rest in `PlaylistEditForm`, which
|
||||||
|
* takes the header's place while it is open.
|
||||||
*/
|
*/
|
||||||
export function PlaylistHeader(
|
export function PlaylistHeader(
|
||||||
{ playlist, onEdit }: PlaylistHeaderProps,
|
{ playlist, onEdit, onSaved }: PlaylistHeaderProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const { user, mediaToken } = useAuth();
|
const { user, mediaToken, authFetch } = useAuth();
|
||||||
|
const save = async (changes: UpdatePlaylistRequest) => {
|
||||||
|
const updated = await expectOk<Wire<Playlist>>(
|
||||||
|
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onSaved?.(deserializePlaylist(updated));
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{playlist.imageMime && (
|
{playlist.imageMime && (
|
||||||
@@ -48,7 +78,17 @@ export function PlaylistHeader(
|
|||||||
)}
|
)}
|
||||||
<div className="playlist-detail-content">
|
<div className="playlist-detail-content">
|
||||||
<div className="playlist-detail-title-row">
|
<div className="playlist-detail-title-row">
|
||||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
<InlineEdit
|
||||||
|
value={playlist.title}
|
||||||
|
editable={!!onSaved}
|
||||||
|
label={t`Edit title`}
|
||||||
|
onSave={(title) => save({ title })}
|
||||||
|
required
|
||||||
|
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||||
|
fieldClassName="playlist-detail-title"
|
||||||
|
>
|
||||||
|
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||||
|
</InlineEdit>
|
||||||
{user?.id !== playlist.userId && (
|
{user?.id !== playlist.userId && (
|
||||||
<FollowPlaylistButton
|
<FollowPlaylistButton
|
||||||
targetPlaylistId={playlist.id}
|
targetPlaylistId={playlist.id}
|
||||||
@@ -66,10 +106,29 @@ export function PlaylistHeader(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{playlist.description && (
|
{(playlist.description || onSaved) && (
|
||||||
<Markdown className="playlist-detail-description">
|
<InlineEdit
|
||||||
{playlist.description}
|
value={playlist.description ?? ""}
|
||||||
</Markdown>
|
editable={!!onSaved}
|
||||||
|
label={t`Edit description`}
|
||||||
|
onSave={(description) => save({ description: description || null })}
|
||||||
|
kind="markdown"
|
||||||
|
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||||
|
placeholder={t`Add a description…`}
|
||||||
|
|
||||||
|
>
|
||||||
|
{playlist.description
|
||||||
|
? (
|
||||||
|
<Markdown className="playlist-detail-description">
|
||||||
|
{playlist.description}
|
||||||
|
</Markdown>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<p className="inline-edit-empty">
|
||||||
|
<Trans>Add a description…</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</InlineEdit>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="playlist-detail-meta">
|
<div className="playlist-detail-meta">
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Renders the inline editor of the signed-in user's description, under their
|
|
||||||
* profile's header.
|
|
||||||
*
|
|
||||||
* @module
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { Trans } from "@lingui/react/macro";
|
|
||||||
import { Controller, FormProvider } from "react-hook-form";
|
|
||||||
import { API_URL } from "../../config/api.ts";
|
|
||||||
import { VALIDATION } from "../../../shared/validation.ts";
|
|
||||||
import type { Wire } from "../../../shared/api.ts";
|
|
||||||
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
|
|
||||||
import { expectOk } from "../../utils/shared/api.ts";
|
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
|
||||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
|
||||||
import { TextEditor } from "../editor/TextEditor.tsx";
|
|
||||||
import { FormError } from "../form/FormError.tsx";
|
|
||||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passed to {@link DescriptionEditor} by `ProfileDescription`, which shows it
|
|
||||||
* in place of the description while editing.
|
|
||||||
*/
|
|
||||||
export interface DescriptionEditorProps {
|
|
||||||
initialDescription: string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSaved: (description: string | undefined) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the description's Markdown editor, saved to `PATCH /api/me`.
|
|
||||||
* Other browsers see the change through the socket's `user_updated` event.
|
|
||||||
*/
|
|
||||||
export function DescriptionEditor(
|
|
||||||
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
|
|
||||||
): React.JSX.Element {
|
|
||||||
const { authFetch } = useAuth();
|
|
||||||
const form = useApiForm<{ description: string }>({
|
|
||||||
defaultValues: { description: initialDescription },
|
|
||||||
});
|
|
||||||
const description = form.watch("description");
|
|
||||||
const submitting = form.formState.isSubmitting;
|
|
||||||
|
|
||||||
const onSubmit = form.submit(async ({ description }) => {
|
|
||||||
const trimmed = description.trim();
|
|
||||||
// Sent even when empty, since the server keeps the old one when it is left
|
|
||||||
// out.
|
|
||||||
await expectOk<Wire<PublicUser>>(
|
|
||||||
await authFetch(`${API_URL}/api/me`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(
|
|
||||||
{ description: trimmed } satisfies UpdateUserRequest,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
onSaved(trimmed || undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<form
|
|
||||||
className="profile-description-editor animate-in"
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
name="description"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextEditor
|
|
||||||
className="comment-reply-textarea"
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onSubmit={() => void onSubmit()}
|
|
||||||
onCancel={submitting ? undefined : onCancel}
|
|
||||||
placeholder={t`Who am I?`}
|
|
||||||
autoResize
|
|
||||||
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="profile-description-actions">
|
|
||||||
<SubmitButton
|
|
||||||
pendingLabel={<Trans>Saving…</Trans>}
|
|
||||||
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
|
|
||||||
>
|
|
||||||
<Trans>Save</Trans>
|
|
||||||
</SubmitButton>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</button>
|
|
||||||
<FormError title={t`Failed to save`} />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Renders the inline editor of the signed-in user's email, in their profile's
|
|
||||||
* header.
|
|
||||||
*
|
|
||||||
* @module
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { Trans } from "@lingui/react/macro";
|
|
||||||
import { FormProvider } from "react-hook-form";
|
|
||||||
import { API_URL } from "../../config/api.ts";
|
|
||||||
import type { Wire } from "../../../shared/api.ts";
|
|
||||||
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
|
|
||||||
import { expectOk } from "../../utils/shared/api.ts";
|
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
|
||||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
|
||||||
import { FormError } from "../form/FormError.tsx";
|
|
||||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passed to {@link EmailEditor} by `ProfileHeader`, which shows it in place of
|
|
||||||
* the email while editing.
|
|
||||||
*/
|
|
||||||
export interface EmailEditorProps {
|
|
||||||
initialEmail: string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSaved: (email: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the email field, saved to `PATCH /api/me`. Escape cancels.
|
|
||||||
*/
|
|
||||||
export function EmailEditor(
|
|
||||||
{ initialEmail, onCancel, onSaved }: EmailEditorProps,
|
|
||||||
): React.JSX.Element {
|
|
||||||
const { authFetch } = useAuth();
|
|
||||||
const form = useApiForm<{ email: string }>({
|
|
||||||
defaultValues: { email: initialEmail },
|
|
||||||
});
|
|
||||||
const email = form.watch("email");
|
|
||||||
const submitting = form.formState.isSubmitting;
|
|
||||||
|
|
||||||
const onSubmit = form.submit(async ({ email }) => {
|
|
||||||
await expectOk<Wire<PublicUser>>(
|
|
||||||
await authFetch(`${API_URL}/api/me`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(
|
|
||||||
{ email: email.trim() } satisfies UpdateUserRequest,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
onSaved(email.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<form className="profile-email-editor animate-in" onSubmit={onSubmit}>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
className="profile-email-input"
|
|
||||||
{...form.register("email")}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") onCancel();
|
|
||||||
}}
|
|
||||||
disabled={submitting}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="profile-email-actions">
|
|
||||||
<SubmitButton
|
|
||||||
className="btn-primary btn-sm"
|
|
||||||
pendingLabel={<Trans>Saving…</Trans>}
|
|
||||||
disabled={!email.trim()}
|
|
||||||
>
|
|
||||||
<Trans>Save</Trans>
|
|
||||||
</SubmitButton>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary btn-sm"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<FormError title={t`Failed to save`} />
|
|
||||||
</form>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,16 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
|
import { API_URL } from "../../config/api.ts";
|
||||||
|
import { VALIDATION } from "../../../shared/validation.ts";
|
||||||
|
import type { Wire } from "../../../shared/api.ts";
|
||||||
|
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
|
||||||
|
import { expectOk } from "../../utils/shared/api.ts";
|
||||||
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
|
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||||
import { Markdown } from "../editor/Markdown.tsx";
|
import { Markdown } from "../editor/Markdown.tsx";
|
||||||
import { DescriptionEditor } from "./DescriptionEditor.tsx";
|
|
||||||
|
|
||||||
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
|
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
|
||||||
export interface ProfileDescriptionProps {
|
export interface ProfileDescriptionProps {
|
||||||
@@ -22,70 +26,53 @@ export interface ProfileDescriptionProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the description in Markdown, or nothing when there is none. On
|
* Renders the description in Markdown, or nothing when there is none. On
|
||||||
* their own profile, users get a "Who am I?" placeholder instead, and a click
|
* their own profile, users get a "Who am I?" placeholder instead, and edit it
|
||||||
* or Enter swaps in {@link DescriptionEditor}.
|
* in place, saved to `PATCH /api/me`. Other browsers see the change through
|
||||||
|
* the socket's `user_updated` event.
|
||||||
*/
|
*/
|
||||||
export function ProfileDescription(
|
export function ProfileDescription(
|
||||||
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps,
|
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps,
|
||||||
): React.JSX.Element | null {
|
): React.JSX.Element | null {
|
||||||
const [editing, setEditing] = useState(false);
|
const { authFetch } = useAuth();
|
||||||
const ref = useHeightTransition<HTMLDivElement>(editing);
|
|
||||||
|
|
||||||
if (!description && !isOwnProfile) return null;
|
if (!description && !isOwnProfile) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="profile-description" ref={ref}>
|
<div className="profile-description">
|
||||||
{editing
|
<InlineEdit
|
||||||
? (
|
value={description ?? ""}
|
||||||
<DescriptionEditor
|
editable={isOwnProfile}
|
||||||
initialDescription={description ?? ""}
|
label={t`Edit description`}
|
||||||
onCancel={() => setEditing(false)}
|
onSave={async (saved) => {
|
||||||
onSaved={(saved) => {
|
// Sent even when empty, since the server keeps the old one when it
|
||||||
onSaved(saved);
|
// is left out.
|
||||||
setEditing(false);
|
await expectOk<Wire<PublicUser>>(
|
||||||
}}
|
await authFetch(`${API_URL}/api/me`, {
|
||||||
/>
|
method: "PATCH",
|
||||||
)
|
headers: { "Content-Type": "application/json" },
|
||||||
: (
|
body: JSON.stringify(
|
||||||
<div
|
{ description: saved } satisfies UpdateUserRequest,
|
||||||
className={`profile-description-view${
|
),
|
||||||
isOwnProfile ? " profile-description-view--editable" : ""
|
}),
|
||||||
}`}
|
);
|
||||||
onClick={isOwnProfile ? () => setEditing(true) : undefined}
|
onSaved(saved || undefined);
|
||||||
// Focusable rather than a button, so the Markdown inside keeps its
|
}}
|
||||||
// own structure and links.
|
kind="markdown"
|
||||||
tabIndex={isOwnProfile ? 0 : undefined}
|
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||||
title={isOwnProfile ? t`Edit description` : undefined}
|
placeholder={t`Who am I?`}
|
||||||
onKeyDown={isOwnProfile
|
>
|
||||||
? (e) => {
|
{description
|
||||||
if (
|
? (
|
||||||
(e.key === "Enter" || e.key === " ") &&
|
<Markdown className="profile-description-text">
|
||||||
e.target === e.currentTarget
|
{description}
|
||||||
) {
|
</Markdown>
|
||||||
e.preventDefault();
|
)
|
||||||
setEditing(true);
|
: (
|
||||||
}
|
<p className="inline-edit-empty">
|
||||||
}
|
<Trans>Who am I?</Trans>
|
||||||
: undefined}
|
</p>
|
||||||
>
|
)}
|
||||||
{description
|
</InlineEdit>
|
||||||
? (
|
|
||||||
<Markdown className="profile-description-text">
|
|
||||||
{description}
|
|
||||||
</Markdown>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="profile-description-empty">
|
|
||||||
<Trans>Who am I?</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isOwnProfile && (
|
|
||||||
<span className="profile-description-edit-btn" aria-hidden>
|
|
||||||
✎
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { API_URL } from "../../config/api.ts";
|
import { API_URL } from "../../config/api.ts";
|
||||||
import type { Wire } from "../../../shared/api.ts";
|
import type { Wire } from "../../../shared/api.ts";
|
||||||
import type { PublicUser, SelfUser } from "../../../shared/user.ts";
|
import type {
|
||||||
import { parseAPIResponse } from "../../utils/shared/api.ts";
|
PublicUser,
|
||||||
|
SelfUser,
|
||||||
|
UpdateUserRequest,
|
||||||
|
} from "../../../shared/user.ts";
|
||||||
|
import { expectOk, parseAPIResponse } from "../../utils/shared/api.ts";
|
||||||
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
|
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
|
|
||||||
import { Avatar } from "../shared/Avatar.tsx";
|
import { Avatar } from "../shared/Avatar.tsx";
|
||||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||||
import { FollowUserButton } from "../social/FollowButton.tsx";
|
import { FollowUserButton } from "../social/FollowButton.tsx";
|
||||||
import { EmailEditor } from "./EmailEditor.tsx";
|
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||||
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
|
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
|
||||||
import { RoleChip } from "./RoleSwitcher.tsx";
|
import { RoleChip } from "./RoleSwitcher.tsx";
|
||||||
import { userPath } from "../../../shared/paths.ts";
|
import { userPath } from "../../../shared/paths.ts";
|
||||||
@@ -38,7 +41,7 @@ export interface ProfileHeaderProps {
|
|||||||
* Renders the avatar, username, role and inviter, and for a user from another
|
* Renders the avatar, username, role and inviter, and for a user from another
|
||||||
* instance, a link to their profile there. Visitors get a follow button. On
|
* instance, a link to their profile there. Visitors get a follow button. On
|
||||||
* their own profile, users change their avatar (`PUT /api/me/avatar`) and
|
* their own profile, users change their avatar (`PUT /api/me/avatar`) and
|
||||||
* email, unless their account comes from another instance, and invite
|
* email (`PATCH /api/me`), unless their account comes from another instance, and invite
|
||||||
* someone; their settings, the site's tools and logging out are in the
|
* someone; their settings, the site's tools and logging out are in the
|
||||||
* header's avatar menu.
|
* header's avatar menu.
|
||||||
*/
|
*/
|
||||||
@@ -49,8 +52,6 @@ export function ProfileHeader(
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [emailEditing, setEmailEditing] = useState(false);
|
|
||||||
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
|
|
||||||
const { name, host } = splitHandle(user.username);
|
const { name, host } = splitHandle(user.username);
|
||||||
// A user from another instance changes their avatar and email there.
|
// A user from another instance changes their avatar and email there.
|
||||||
const editable = isOwnProfile && !host;
|
const editable = isOwnProfile && !host;
|
||||||
@@ -83,9 +84,15 @@ export function ProfileHeader(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmailSaved = (email: string) => {
|
const handleEmailSave = async (email: string) => {
|
||||||
|
await expectOk<Wire<PublicUser>>(
|
||||||
|
await authFetch(`${API_URL}/api/me`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email } satisfies UpdateUserRequest),
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (me) updateSession({ user: { ...me, email } });
|
if (me) updateSession({ user: { ...me, email } });
|
||||||
setEmailEditing(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,7 +119,7 @@ export function ProfileHeader(
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-info" ref={infoRef}>
|
<div className="profile-info">
|
||||||
<div className="profile-username-row">
|
<div className="profile-username-row">
|
||||||
<h1 className="profile-username">
|
<h1 className="profile-username">
|
||||||
{name}
|
{name}
|
||||||
@@ -148,34 +155,19 @@ export function ProfileHeader(
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{editable && (
|
{editable && (
|
||||||
emailEditing
|
<InlineEdit
|
||||||
? (
|
value={me?.email ?? ""}
|
||||||
<EmailEditor
|
editable
|
||||||
initialEmail={me?.email ?? ""}
|
label={t`Edit email`}
|
||||||
onCancel={() => setEmailEditing(false)}
|
onSave={handleEmailSave}
|
||||||
onSaved={handleEmailSaved}
|
kind="email"
|
||||||
/>
|
required
|
||||||
)
|
className="profile-email"
|
||||||
: (
|
>
|
||||||
<p
|
<span className="profile-email-text">
|
||||||
className="profile-email-display"
|
{me?.email ?? t`Add email…`}
|
||||||
onClick={() => setEmailEditing(true)}
|
</span>
|
||||||
onKeyDown={(e) => {
|
</InlineEdit>
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setEmailEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
title={t`Edit email`}
|
|
||||||
>
|
|
||||||
{me?.email ?? t`Add email…`}
|
|
||||||
<span className="profile-description-edit-btn" aria-hidden>
|
|
||||||
✎
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
{avatarError && (
|
{avatarError && (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
|
|||||||
@@ -33,12 +33,15 @@ import {
|
|||||||
deserializePlaylist,
|
deserializePlaylist,
|
||||||
deserializePublicUser,
|
deserializePublicUser,
|
||||||
} from "../utils/shared/deserialize.ts";
|
} from "../utils/shared/deserialize.ts";
|
||||||
import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts";
|
import {
|
||||||
|
expectOk,
|
||||||
|
friendlyFetchError,
|
||||||
|
parseAPIResponse,
|
||||||
|
} from "../utils/shared/api.ts";
|
||||||
|
|
||||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||||
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
|
||||||
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
|
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
||||||
import { Avatar } from "../components/shared/Avatar.tsx";
|
import { Avatar } from "../components/shared/Avatar.tsx";
|
||||||
@@ -52,6 +55,7 @@ import { PageError } from "../components/layout/PageError.tsx";
|
|||||||
import { Markdown } from "../components/editor/Markdown.tsx";
|
import { Markdown } from "../components/editor/Markdown.tsx";
|
||||||
import { CommentThread } from "../components/comment/CommentThread.tsx";
|
import { CommentThread } from "../components/comment/CommentThread.tsx";
|
||||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||||
|
import { InlineEdit } from "../components/form/InlineEdit.tsx";
|
||||||
import { userPath } from "../../shared/paths.ts";
|
import { userPath } from "../../shared/paths.ts";
|
||||||
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
|
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
|
||||||
|
|
||||||
@@ -67,9 +71,10 @@ type DumpState =
|
|||||||
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
|
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
|
||||||
* slug, the URL takes the current ones. Its comments, related dumps and
|
* slug, the URL takes the current ones. Its comments, related dumps and
|
||||||
* playlists come from their own endpoints once it is loaded, and the socket's
|
* playlists come from their own endpoints once it is loaded, and the socket's
|
||||||
* events keep them current; a reconnection fetches them and the dump again. A dump deleted, or made private by its poster, while someone else
|
* events keep them current; a reconnection fetches them and the dump again.
|
||||||
* views it gives way to a notice. The poster and moderators can edit the
|
* A dump deleted, or made private by its poster, while someone else views it
|
||||||
* title in place, and get a link to `DumpEdit`.
|
* gives way to a notice. The poster and moderators can edit the title and the
|
||||||
|
* note in place, and get a link to `DumpEdit` for the rest.
|
||||||
*/
|
*/
|
||||||
export function Dump(): React.JSX.Element {
|
export function Dump(): React.JSX.Element {
|
||||||
const { handle, slug } = useParams();
|
const { handle, slug } = useParams();
|
||||||
@@ -129,12 +134,6 @@ export function Dump(): React.JSX.Element {
|
|||||||
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
|
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
|
||||||
const [collections, setCollections] = useState<Playlist[]>([]);
|
const [collections, setCollections] = useState<Playlist[]>([]);
|
||||||
|
|
||||||
const [titleEditing, setTitleEditing] = useState(false);
|
|
||||||
const headerRightRef = useHeightTransition<HTMLDivElement>(titleEditing);
|
|
||||||
const [titleDraft, setTitleDraft] = useState("");
|
|
||||||
const [titleSaving, setTitleSaving] = useState(false);
|
|
||||||
const [titleError, setTitleError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { user, mediaToken, authFetch } = useAuth();
|
const { user, mediaToken, authFetch } = useAuth();
|
||||||
const {
|
const {
|
||||||
voteCounts,
|
voteCounts,
|
||||||
@@ -409,45 +408,23 @@ export function Dump(): React.JSX.Element {
|
|||||||
const canEdit = !!user &&
|
const canEdit = !!user &&
|
||||||
(dump.userId === user.id || can(user, "dump:moderate"));
|
(dump.userId === user.id || can(user, "dump:moderate"));
|
||||||
|
|
||||||
const startTitleEdit = () => {
|
// Saves what the title or the note was changed to in place. The page stays
|
||||||
setTitleDraft(dump.title);
|
// mounted across a slug change, and would keep showing the dump it had.
|
||||||
setTitleError(null);
|
const saveDump = async (changes: UpdateDumpRequest) => {
|
||||||
setTitleEditing(true);
|
const updatedDump = deserializeDump(
|
||||||
};
|
await expectOk<Wire<Dump>>(
|
||||||
|
await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
||||||
const handleTitleSave = async () => {
|
method: "PUT",
|
||||||
const trimmed = titleDraft.trim();
|
body: JSON.stringify(changes),
|
||||||
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
|
}),
|
||||||
|
),
|
||||||
setTitleSaving(true);
|
);
|
||||||
setTitleError(null);
|
setDumpState({ status: "loaded", dump: updatedDump });
|
||||||
try {
|
if (updatedDump.slug !== dump.slug) {
|
||||||
const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
navigate(dumpUrl(updatedDump), {
|
||||||
method: "PUT",
|
replace: true,
|
||||||
body: JSON.stringify(
|
state: { dump: updatedDump },
|
||||||
{ title: trimmed } satisfies UpdateDumpRequest,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
|
|
||||||
if (!apiResponse.success) {
|
|
||||||
setTitleError(apiResponse.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const updatedDump = deserializeDump(apiResponse.data);
|
|
||||||
setTitleEditing(false);
|
|
||||||
// The page stays mounted across the slug change below, and would keep
|
|
||||||
// the old title.
|
|
||||||
setDumpState({ status: "loaded", dump: updatedDump });
|
|
||||||
if (updatedDump.slug !== dump.slug) {
|
|
||||||
navigate(dumpUrl(updatedDump), {
|
|
||||||
replace: true,
|
|
||||||
state: { dump: updatedDump },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setTitleError(friendlyFetchError(err));
|
|
||||||
} finally {
|
|
||||||
setTitleSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -475,79 +452,18 @@ export function Dump(): React.JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dump-header-right" ref={headerRightRef}>
|
<div className="dump-header-right">
|
||||||
{titleEditing
|
<InlineEdit
|
||||||
? (
|
value={dump.title}
|
||||||
<div className="dump-title-editor animate-in">
|
editable={canEdit}
|
||||||
<input
|
label={t`Edit title`}
|
||||||
type="text"
|
onSave={(title) => saveDump({ title })}
|
||||||
className="dump-title-input"
|
required
|
||||||
value={titleDraft}
|
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||||
onChange={(e) => setTitleDraft(e.target.value)}
|
fieldClassName="dump-title"
|
||||||
onKeyDown={(e) => {
|
>
|
||||||
if (e.key === "Enter") {
|
<h1 className="dump-title">{dump.title}</h1>
|
||||||
e.preventDefault();
|
</InlineEdit>
|
||||||
handleTitleSave();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setTitleEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
|
||||||
disabled={titleSaving}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="dump-title-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-primary"
|
|
||||||
onClick={handleTitleSave}
|
|
||||||
disabled={titleSaving || !titleDraft.trim()}
|
|
||||||
>
|
|
||||||
{titleSaving
|
|
||||||
? <Trans>Saving…</Trans>
|
|
||||||
: <Trans>Save</Trans>}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={() => setTitleEditing(false)}
|
|
||||||
disabled={titleSaving}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{titleError && (
|
|
||||||
<p className="dump-title-error">{titleError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div
|
|
||||||
className={`dump-title-view${
|
|
||||||
canEdit ? " dump-title-view--editable" : ""
|
|
||||||
}`}
|
|
||||||
onClick={canEdit ? startTitleEdit : undefined}
|
|
||||||
// Reachable from the keyboard too; not a button, so the
|
|
||||||
// title inside stays a heading.
|
|
||||||
tabIndex={canEdit ? 0 : undefined}
|
|
||||||
title={canEdit ? t`Edit title` : undefined}
|
|
||||||
onKeyDown={canEdit
|
|
||||||
? (e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
startTitleEdit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined}
|
|
||||||
>
|
|
||||||
<h1 className="dump-title">{dump.title}</h1>
|
|
||||||
{canEdit && (
|
|
||||||
<span className="dump-title-edit-btn" aria-hidden>
|
|
||||||
✎
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="dump-op">
|
<div className="dump-op">
|
||||||
<Avatar
|
<Avatar
|
||||||
userId={dump.userId}
|
userId={dump.userId}
|
||||||
@@ -587,8 +503,25 @@ export function Dump(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dump.comment && (
|
{(dump.comment || canEdit) && (
|
||||||
<Markdown className="dump-comment">{dump.comment}</Markdown>
|
<InlineEdit
|
||||||
|
value={dump.comment ?? ""}
|
||||||
|
editable={canEdit}
|
||||||
|
label={t`Edit note`}
|
||||||
|
onSave={(comment) => saveDump({ comment: comment || null })}
|
||||||
|
kind="markdown"
|
||||||
|
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||||
|
placeholder={t`What makes it worth it?`}
|
||||||
|
|
||||||
|
>
|
||||||
|
{dump.comment
|
||||||
|
? <Markdown className="dump-comment">{dump.comment}</Markdown>
|
||||||
|
: (
|
||||||
|
<p className="inline-edit-empty">
|
||||||
|
<Trans>What makes it worth it?</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</InlineEdit>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export interface PlaylistDetailProps {
|
|||||||
* a private one only to its owner. `usePlaylist` loads it, by a former name or
|
* a private one only to its owner. `usePlaylist` loads it, by a former name or
|
||||||
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`,
|
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`,
|
||||||
* can drag its dumps into a new order and remove one, with a short window to
|
* can drag its dumps into a new order and remove one, with a short window to
|
||||||
* undo, and edit or delete it in the editor that opens over its header at
|
* undo, edit its title and description in place, and edit the rest or delete
|
||||||
|
* it in the editor that opens over its header at
|
||||||
* `/@:username/playlists/:slug/edit`. Both paths render the page at the same
|
* `/@:username/playlists/:slug/edit`. Both paths render the page at the same
|
||||||
* place, so opening the editor keeps the playlist loaded. Anyone else there
|
* place, so opening the editor keeps the playlist loaded. Anyone else there
|
||||||
* gets the page, or is asked to sign in first.
|
* gets the page, or is asked to sign in first.
|
||||||
@@ -143,9 +144,10 @@ export function PlaylistDetail(
|
|||||||
<div className="playlist-detail-header-top">
|
<div className="playlist-detail-header-top">
|
||||||
<PlaylistHeader
|
<PlaylistHeader
|
||||||
playlist={playlist}
|
playlist={playlist}
|
||||||
onEdit={canEdit
|
{...(canEdit && {
|
||||||
? () => navigate(playlistUrl(playlist, "edit"))
|
onEdit: () => navigate(playlistUrl(playlist, "edit")),
|
||||||
: undefined}
|
onSaved: applyDetails,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
88
src/test/components/form/InlineEdit.test.tsx
Normal file
88
src/test/components/form/InlineEdit.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { I18nProvider } from "@lingui/react";
|
||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
import { InlineEdit } from "../../../components/form/InlineEdit.tsx";
|
||||||
|
|
||||||
|
function renderEdit(
|
||||||
|
props: Partial<React.ComponentProps<typeof InlineEdit>> = {},
|
||||||
|
) {
|
||||||
|
const onSave = vi.fn(async (_: string) => {});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<I18nProvider i18n={i18n}>
|
||||||
|
<InlineEdit
|
||||||
|
value="Old"
|
||||||
|
editable
|
||||||
|
label="Edit name"
|
||||||
|
onSave={onSave}
|
||||||
|
required
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<h2>
|
||||||
|
Old <a href="#elsewhere">link</a>
|
||||||
|
</h2>
|
||||||
|
</InlineEdit>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
return { user, onSave };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("InlineEdit", () => {
|
||||||
|
test("without the right to edit, only the text shows", () => {
|
||||||
|
renderEdit({ editable: false });
|
||||||
|
expect(screen.getByRole("heading")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a click on the text opens the field; Enter saves it trimmed", async () => {
|
||||||
|
const { user, onSave } = renderEdit();
|
||||||
|
await user.click(screen.getByRole("heading"));
|
||||||
|
const input = screen.getByRole("textbox", { name: "Edit name" });
|
||||||
|
expect(input).toHaveFocus();
|
||||||
|
await user.type(input, "er {Enter}");
|
||||||
|
expect(onSave).toHaveBeenCalledWith("Older");
|
||||||
|
expect(screen.getByRole("heading")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a link in the text is followed, not edited", async () => {
|
||||||
|
const { user } = renderEdit();
|
||||||
|
await user.click(screen.getByRole("link", { name: "link" }));
|
||||||
|
expect(screen.queryByRole("textbox")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("left as it was, nothing is sent", async () => {
|
||||||
|
const { user, onSave } = renderEdit();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit name" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole("textbox")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty required text can't be saved", async () => {
|
||||||
|
const { user } = renderEdit();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit name" }));
|
||||||
|
await user.clear(screen.getByRole("textbox"));
|
||||||
|
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Escape backs out, and the focus goes back to the ✎", async () => {
|
||||||
|
const { user, onSave } = renderEdit();
|
||||||
|
screen.getByRole("button", { name: "Edit name" }).focus();
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
await user.type(screen.getByRole("textbox"), "x{Escape}");
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("button", { name: "Edit name" })).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed save stays open and says why", async () => {
|
||||||
|
const { user } = renderEdit({
|
||||||
|
onSave: () => Promise.reject(new Error("Taken")),
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole("heading"));
|
||||||
|
await user.type(screen.getByRole("textbox"), "!{Enter}");
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("Taken");
|
||||||
|
expect(screen.getByRole("textbox")).toHaveValue("Old!");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -135,6 +135,35 @@ describe("Dump page", () => {
|
|||||||
expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the owner writes the note in place; emptied, it goes", async () => {
|
||||||
|
dumpRoutes();
|
||||||
|
route(
|
||||||
|
"PUT /api/dumps/:id",
|
||||||
|
() =>
|
||||||
|
wire(makeDump({
|
||||||
|
id: "d1",
|
||||||
|
slug: "a-find",
|
||||||
|
title: "A find",
|
||||||
|
userId: "owner",
|
||||||
|
comment: undefined,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const { user } = openDump(owner);
|
||||||
|
await user.click(await screen.findByText("reasons"));
|
||||||
|
await user.clear(screen.getByDisplayValue("Because **reasons**"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(await screen.findByText("What makes it worth it?"))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ comment: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a visitor sees no note where there is none", async () => {
|
||||||
|
dumpRoutes(makeDump({ slug: "a-find", title: "A find", userId: "owner" }));
|
||||||
|
openDump(other);
|
||||||
|
await screen.findByRole("heading", { name: "A find" });
|
||||||
|
expect(screen.queryByText("What makes it worth it?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("others can't rename or edit it", async () => {
|
test("others can't rename or edit it", async () => {
|
||||||
dumpRoutes();
|
dumpRoutes();
|
||||||
const { user } = openDump(other);
|
const { user } = openDump(other);
|
||||||
@@ -157,7 +186,7 @@ describe("Dump page", () => {
|
|||||||
dumpRoutes();
|
dumpRoutes();
|
||||||
const { user } = openDump(owner);
|
const { user } = openDump(owner);
|
||||||
await screen.findByRole("heading", { name: "A find" });
|
await screen.findByRole("heading", { name: "A find" });
|
||||||
screen.getByTitle("Edit title").focus();
|
screen.getByRole("button", { name: "Edit title" }).focus();
|
||||||
await user.keyboard("{Enter}");
|
await user.keyboard("{Enter}");
|
||||||
expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ describe("PlaylistDetail", () => {
|
|||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
|
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Edit title" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the cover is versioned on the last edit", async () => {
|
test("the cover is versioned on the last edit", async () => {
|
||||||
@@ -140,6 +141,29 @@ describe("PlaylistDetail", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("its owner retitles it in place, which keeps its link", async () => {
|
||||||
|
route(
|
||||||
|
"PUT /api/playlists/:id",
|
||||||
|
() =>
|
||||||
|
wire(makePlaylist({
|
||||||
|
id: "p1",
|
||||||
|
slug: "mix",
|
||||||
|
title: "Remix",
|
||||||
|
userId: "owner",
|
||||||
|
description: "Good **tunes**",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const { user } = open();
|
||||||
|
await user.click(await screen.findByRole("heading", { name: "Mix" }));
|
||||||
|
const input = screen.getByDisplayValue("Mix");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "Remix{Enter}");
|
||||||
|
expect(await screen.findByRole("heading", { name: "Remix" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(currentPath()).toBe("/@owner/playlists/mix");
|
||||||
|
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ title: "Remix" });
|
||||||
|
});
|
||||||
|
|
||||||
test("the editor is a page: /edit opens it, closing goes back to the page", async () => {
|
test("the editor is a page: /edit opens it, closing goes back to the page", async () => {
|
||||||
const { user } = open({ url: "/@owner/playlists/mix/edit" });
|
const { user } = open({ url: "/@owner/playlists/mix/edit" });
|
||||||
expect(await screen.findByRole("textbox", { name: "Description" }))
|
expect(await screen.findByRole("textbox", { name: "Description" }))
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ describe("UserPublicProfile", () => {
|
|||||||
`${API_URL}/api/users/@alice%40home.example/home`,
|
`${API_URL}/api/users/@alice%40home.example/home`,
|
||||||
);
|
);
|
||||||
expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument();
|
expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTitle("Edit email")).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Edit email" })).not
|
||||||
|
.toBeInTheDocument();
|
||||||
expect(screen.queryByText("O.G.")).not.toBeInTheDocument();
|
expect(screen.queryByText("O.G.")).not.toBeInTheDocument();
|
||||||
expect(calls("GET /api/users/:u")[0].path).toBe(
|
expect(calls("GET /api/users/:u")[0].path).toBe(
|
||||||
"/api/users/@alice%40home.example",
|
"/api/users/@alice%40home.example",
|
||||||
@@ -451,10 +452,8 @@ describe("UserPublicProfile", () => {
|
|||||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||||
);
|
);
|
||||||
const { user } = open({ session: annSession });
|
const { user } = open({ session: annSession });
|
||||||
const view = (await screen.findByText("dump")).closest(
|
await screen.findByText("dump");
|
||||||
"[title='Edit description']",
|
screen.getByRole("button", { name: "Edit description" }).focus();
|
||||||
) as HTMLElement;
|
|
||||||
view.focus();
|
|
||||||
await user.keyboard("{Enter}");
|
await user.keyboard("{Enter}");
|
||||||
const box = screen.getByPlaceholderText("Who am I?");
|
const box = screen.getByPlaceholderText("Who am I?");
|
||||||
await user.clear(box);
|
await user.clear(box);
|
||||||
@@ -469,14 +468,11 @@ describe("UserPublicProfile", () => {
|
|||||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||||
);
|
);
|
||||||
const { user } = open({ session: annSession });
|
const { user } = open({ session: annSession });
|
||||||
await user.click(
|
await user.click(await screen.findByText("ann@x.test"));
|
||||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
|
||||||
);
|
|
||||||
const input = screen.getByDisplayValue("ann@x.test");
|
const input = screen.getByDisplayValue("ann@x.test");
|
||||||
await user.clear(input);
|
await user.clear(input);
|
||||||
await user.type(input, "new@x.test{Enter}");
|
await user.type(input, "new@x.test{Enter}");
|
||||||
expect(await screen.findByRole("button", { name: /new@x\.test/ }))
|
expect(await screen.findByText("new@x.test")).toBeInTheDocument();
|
||||||
.toBeInTheDocument();
|
|
||||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
|
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
|
||||||
.toBe("new@x.test");
|
.toBe("new@x.test");
|
||||||
});
|
});
|
||||||
@@ -484,9 +480,7 @@ describe("UserPublicProfile", () => {
|
|||||||
test("a refused email says why", async () => {
|
test("a refused email says why", async () => {
|
||||||
route("PATCH /api/me", () => fail("Email already in use", 409));
|
route("PATCH /api/me", () => fail("Email already in use", 409));
|
||||||
const { user } = open({ session: annSession });
|
const { user } = open({ session: annSession });
|
||||||
await user.click(
|
await user.click(await screen.findByText("ann@x.test"));
|
||||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
|
||||||
);
|
|
||||||
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
|
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
"Email already in use",
|
"Email already in use",
|
||||||
|
|||||||
@@ -172,7 +172,8 @@
|
|||||||
[data-style="brutalist"] .user-menu-trigger,
|
[data-style="brutalist"] .user-menu-trigger,
|
||||||
[data-style="brutalist"] .user-menu-item,
|
[data-style="brutalist"] .user-menu-item,
|
||||||
[data-style="brutalist"] .form-cancel,
|
[data-style="brutalist"] .form-cancel,
|
||||||
[data-style="brutalist"] .form-label-action {
|
[data-style="brutalist"] .form-label-action,
|
||||||
|
[data-style="brutalist"] .inline-edit-mark {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
@@ -184,7 +185,9 @@
|
|||||||
[data-style="brutalist"] .form-cancel:hover:not(:disabled),
|
[data-style="brutalist"] .form-cancel:hover:not(:disabled),
|
||||||
[data-style="brutalist"] .form-cancel:active,
|
[data-style="brutalist"] .form-cancel:active,
|
||||||
[data-style="brutalist"] .form-label-action:hover:not(:disabled),
|
[data-style="brutalist"] .form-label-action:hover:not(:disabled),
|
||||||
[data-style="brutalist"] .form-label-action:active {
|
[data-style="brutalist"] .form-label-action:active,
|
||||||
|
[data-style="brutalist"] .inline-edit-mark:hover:not(:disabled),
|
||||||
|
[data-style="brutalist"] .inline-edit-mark:active {
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,7 +308,7 @@
|
|||||||
|
|
||||||
/* Pressed: the bevel turns inside out and the label moves down and right a
|
/* Pressed: the bevel turns inside out and the label moves down and right a
|
||||||
pixel with the face. */
|
pixel with the face. */
|
||||||
[data-style="geocities"] button:not(.dump-fab, .chat-fab, .btn--ghost, .user-menu-trigger, .user-menu-item):active:not(:disabled, [aria-disabled="true"]),
|
[data-style="geocities"] button:not(.dump-fab, .chat-fab, .btn--ghost, .user-menu-trigger, .user-menu-item, .inline-edit-mark):active:not(:disabled, [aria-disabled="true"]),
|
||||||
[data-style="geocities"] .btn-primary:active:not(:disabled, [aria-disabled="true"]),
|
[data-style="geocities"] .btn-primary:active:not(:disabled, [aria-disabled="true"]),
|
||||||
[data-style="geocities"] .btn-secondary:active:not(:disabled, [aria-disabled="true"]),
|
[data-style="geocities"] .btn-secondary:active:not(:disabled, [aria-disabled="true"]),
|
||||||
[data-style="geocities"] .btn-border:active:not(:disabled, [aria-disabled="true"]),
|
[data-style="geocities"] .btn-border:active:not(:disabled, [aria-disabled="true"]),
|
||||||
@@ -653,13 +653,16 @@
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The crawl stops under the pointer, and for a link that has the focus: the
|
/* The crawl stops under the pointer, and for a link the keyboard has
|
||||||
line then stands at its start, and the browser scrolls it to that link. */
|
focused: the line then stands at its start, and the browser scrolls it to
|
||||||
|
that link. A link the pointer presses takes the focus too, but is left
|
||||||
|
where it is, paused under the pointer: brought back to the start, it would
|
||||||
|
slip away before the button came up, and the click with it. */
|
||||||
[data-style="geocities"] .site-marquee:hover .site-marquee-track {
|
[data-style="geocities"] .site-marquee:hover .site-marquee-track {
|
||||||
animation-play-state: paused;
|
animation-play-state: paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-style="geocities"] .site-marquee:focus-within .site-marquee-track {
|
[data-style="geocities"] .site-marquee:has(:focus-visible) .site-marquee-track {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
.form-field,
|
.form-field,
|
||||||
.profile-role-chip,
|
.profile-role-chip,
|
||||||
.profile-invited-by,
|
.profile-invited-by,
|
||||||
.profile-email-display,
|
.profile-email,
|
||||||
.profile-section-header,
|
.profile-section-header,
|
||||||
.profile-appearance-row,
|
.profile-appearance-row,
|
||||||
.playlist-badge,
|
.playlist-badge,
|
||||||
|
|||||||
Reference in New Issue
Block a user