v4: the playlists page is reachable at every width, and lists the user's own — below 1150px the header's text link was hidden and nothing replaced it: the avatar menu's "My playlists" led to the user's own, and a guest has no menu. The menu's entry is now "Playlists", to /playlists, at every width, and the page gains a tab after its orders, "Mine", for a signed-in user: their own playlists, private ones too, from GET /api/users/:id/playlists in the default order, narrowed by the category menu and kept live as the others are. It is /playlists?mine=1 (PLAYLISTS_TABS, parsePlaylistsTab, shared/paths.ts), a tab taking the order's place; a guest isn't offered it, and its link sends one to sign in. PlaylistSortTabs takes the tabs it shows, a profile's being the orders alone. The text link shows to a guest at every width, their header giving up its margins under 400px rather than the link, and to a signed-in user where the row has room beside the icons, as measured in every style and both languages: from 560px up to 860px, where the index page's tabs move into the header, and from 1150px as before. A guest in French in the kanagawa style still overflows the header under 340px, by up to 19px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
9
API.md
9
API.md
@@ -16,9 +16,10 @@ the page is the category's own, a dump category's feed at the root (`/music`)
|
|||||||
and a playlist category's under `/playlists` (`/playlists/mixes`); where it
|
and a playlist category's under `/playlists` (`/playlists/mixes`); where it
|
||||||
narrows a list on another page, a profile's or a search's, it is `?category=`,
|
narrows a list on another page, a profile's or a search's, it is `?category=`,
|
||||||
of the kind that list holds. The order of a list of playlists is `?sort=`, the
|
of the kind that list holds. The order of a list of playlists is `?sort=`, the
|
||||||
default order being the bare path. Switching tabs, a tab's views or a list's
|
default order being the bare path, and the playlists page's list of the viewer's
|
||||||
order takes the place of the current history entry; picking a category, or
|
own is `?mine=1`. Switching tabs, a tab's views or a list's order takes the
|
||||||
opening another page or an editor, adds one.
|
place of the current history entry; picking a category, or opening another page
|
||||||
|
or an editor, adds one.
|
||||||
|
|
||||||
**API.** A resource is `/api/<plural>/<key>[/<noun>]`. Its key is its id, except
|
**API.** A resource is `/api/<plural>/<key>[/<noun>]`. Its key is its id, except
|
||||||
a user's, which may also be `@name`. The `@` keeps the two apart, so no regex
|
a user's, which may also be `@name`. The `@` keeps the two apart, so no regex
|
||||||
@@ -41,7 +42,7 @@ has one word, the same as in pages.
|
|||||||
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
||||||
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
||||||
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
|
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
|
||||||
| `/playlists` | `/playlists/<category>`, a playlist category's; `?sort=new`, `?sort=dumps` order either | anyone | `Playlists` |
|
| `/playlists` | `/playlists/<category>`, a playlist category's; `?sort=new`, `?sort=dumps` order either; `?mine=1` lists the viewer's own, signed in only | anyone | `Playlists` |
|
||||||
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` |
|
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` |
|
||||||
| `/notifications` | | signed in | `Notifications` |
|
| `/notifications` | | signed in | `Notifications` |
|
||||||
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -450,7 +450,7 @@ msgstr "Cancel removal"
|
|||||||
msgid "Cancel reply"
|
msgid "Cancel reply"
|
||||||
msgstr "Cancel reply"
|
msgstr "Cancel reply"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:177
|
#: src/components/layout/AppHeader.tsx:181
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Cancel search"
|
msgstr "Cancel search"
|
||||||
|
|
||||||
@@ -579,7 +579,7 @@ msgstr "Could not change password"
|
|||||||
msgid "Could not connect to the server. Check your connection and try again."
|
msgid "Could not connect to the server. Check your connection and try again."
|
||||||
msgstr "Could not connect to the server. Check your connection and try again."
|
msgstr "Could not connect to the server. Check your connection and try again."
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:192
|
#: src/components/profile/ProfilePlaylistsTab.tsx:194
|
||||||
#: src/pages/DumpEdit.tsx:555
|
#: src/pages/DumpEdit.tsx:555
|
||||||
msgid "Could not delete"
|
msgid "Could not delete"
|
||||||
msgstr "Could not delete"
|
msgstr "Could not delete"
|
||||||
@@ -681,7 +681,7 @@ msgstr "Delete failed ({0})"
|
|||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:162
|
#: src/components/playlist/PlaylistCard.tsx:162
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:222
|
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||||
#: src/pages/PlaylistDetail.tsx:172
|
#: src/pages/PlaylistDetail.tsx:172
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Delete playlist"
|
msgstr "Delete playlist"
|
||||||
@@ -698,7 +698,7 @@ msgstr "Delete this dump? This cannot be undone."
|
|||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Delete this message?"
|
msgstr "Delete this message?"
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:221
|
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||||
#: src/pages/PlaylistDetail.tsx:171
|
#: src/pages/PlaylistDetail.tsx:171
|
||||||
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."
|
||||||
@@ -753,7 +753,7 @@ msgstr "Drop a file here, or paste one"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Drop a replacement here"
|
msgstr "Drop a replacement here"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:196
|
#: src/components/layout/AppHeader.tsx:200
|
||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Dump"
|
msgstr "Dump"
|
||||||
|
|
||||||
@@ -1114,7 +1114,7 @@ msgstr "Link or file"
|
|||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:229
|
#: src/components/layout/AppHeader.tsx:233
|
||||||
msgid "Live updates unavailable."
|
msgid "Live updates unavailable."
|
||||||
msgstr "Live updates unavailable."
|
msgstr "Live updates unavailable."
|
||||||
|
|
||||||
@@ -1159,7 +1159,7 @@ msgstr "Loading profile…"
|
|||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Loading…"
|
msgstr "Loading…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:203
|
#: src/components/layout/AppHeader.tsx:207
|
||||||
#: src/pages/FederationCallback.tsx:32
|
#: src/pages/FederationCallback.tsx:32
|
||||||
#: src/pages/UserLogin.tsx:41
|
#: src/pages/UserLogin.tsx:41
|
||||||
#: src/pages/UserLogin.tsx:66
|
#: src/pages/UserLogin.tsx:66
|
||||||
@@ -1180,7 +1180,7 @@ msgstr "Log in to vote"
|
|||||||
msgid "Log in with {0}"
|
msgid "Log in with {0}"
|
||||||
msgstr "Log in with {0}"
|
msgstr "Log in with {0}"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:128
|
#: src/components/layout/UserMenu.tsx:132
|
||||||
#: src/pages/UserPublicProfile.tsx:137
|
#: src/pages/UserPublicProfile.tsx:137
|
||||||
msgid "Log out"
|
msgid "Log out"
|
||||||
msgstr "Log out"
|
msgstr "Log out"
|
||||||
@@ -1198,7 +1198,7 @@ msgstr "Login failed"
|
|||||||
msgid "Lowercase letters, numbers and single hyphens only"
|
msgid "Lowercase letters, numbers and single hyphens only"
|
||||||
msgstr "Lowercase letters, numbers and single hyphens only"
|
msgstr "Lowercase letters, numbers and single hyphens only"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:115
|
#: src/components/layout/UserMenu.tsx:119
|
||||||
#: src/pages/Manage.tsx:32
|
#: src/pages/Manage.tsx:32
|
||||||
#: src/pages/Manage.tsx:40
|
#: src/pages/Manage.tsx:40
|
||||||
#: src/pages/UserPublicProfile.tsx:170
|
#: src/pages/UserPublicProfile.tsx:170
|
||||||
@@ -1209,6 +1209,10 @@ msgstr "Manage"
|
|||||||
msgid "Max {limit}"
|
msgid "Max {limit}"
|
||||||
msgstr "Max {limit}"
|
msgstr "Max {limit}"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:34
|
||||||
|
msgid "Mine"
|
||||||
|
msgstr "Mine"
|
||||||
|
|
||||||
#: src/components/profile/RoleSwitcher.tsx:23
|
#: src/components/profile/RoleSwitcher.tsx:23
|
||||||
msgid "Moderator"
|
msgid "Moderator"
|
||||||
msgstr "Moderator"
|
msgstr "Moderator"
|
||||||
@@ -1218,7 +1222,7 @@ msgstr "Moderator"
|
|||||||
msgid "digest.monthly"
|
msgid "digest.monthly"
|
||||||
msgstr "Monthly recap"
|
msgstr "Monthly recap"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistSortTabs.tsx:27
|
#: src/components/playlist/PlaylistSortTabs.tsx:33
|
||||||
msgid "Most dumps"
|
msgid "Most dumps"
|
||||||
msgstr "Most dumps"
|
msgstr "Most dumps"
|
||||||
|
|
||||||
@@ -1226,10 +1230,6 @@ msgstr "Most dumps"
|
|||||||
msgid "Mute"
|
msgid "Mute"
|
||||||
msgstr "Mute"
|
msgstr "Mute"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:89
|
|
||||||
msgid "My playlists"
|
|
||||||
msgstr "My playlists"
|
|
||||||
|
|
||||||
#: src/components/category/CategoryManager.tsx:208
|
#: src/components/category/CategoryManager.tsx:208
|
||||||
#: src/components/category/CategoryManager.tsx:209
|
#: src/components/category/CategoryManager.tsx:209
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
@@ -1237,7 +1237,7 @@ msgstr "Name"
|
|||||||
|
|
||||||
#: src/components/chat/ChatModal.tsx:692
|
#: src/components/chat/ChatModal.tsx:692
|
||||||
#: src/components/feed/FeedTabBar.tsx:58
|
#: src/components/feed/FeedTabBar.tsx:58
|
||||||
#: src/components/playlist/PlaylistSortTabs.tsx:26
|
#: src/components/playlist/PlaylistSortTabs.tsx:32
|
||||||
#: src/pages/Settings.tsx:55
|
#: src/pages/Settings.tsx:55
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "New"
|
msgstr "New"
|
||||||
@@ -1315,8 +1315,8 @@ msgid "No playlists match \"{q}\"."
|
|||||||
msgstr "No playlists match \"{q}\"."
|
msgstr "No playlists match \"{q}\"."
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:199
|
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||||
#: src/pages/Playlists.tsx:151
|
#: src/pages/Playlists.tsx:180
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "No playlists yet."
|
msgstr "No playlists yet."
|
||||||
|
|
||||||
@@ -1477,11 +1477,12 @@ msgid "Playlist categories"
|
|||||||
msgstr "Playlist categories"
|
msgstr "Playlist categories"
|
||||||
|
|
||||||
#: src/components/dump/create/StepIndicator.tsx:21
|
#: src/components/dump/create/StepIndicator.tsx:21
|
||||||
#: src/components/layout/AppHeader.tsx:169
|
#: src/components/layout/AppHeader.tsx:173
|
||||||
|
#: src/components/layout/UserMenu.tsx:93
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:147
|
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:161
|
#: src/components/profile/ProfilePlaylistsTab.tsx:162
|
||||||
#: src/pages/Playlists.tsx:60
|
#: src/pages/Playlists.tsx:74
|
||||||
#: src/pages/Playlists.tsx:132
|
#: src/pages/Playlists.tsx:160
|
||||||
#: src/pages/Search.tsx:214
|
#: src/pages/Search.tsx:214
|
||||||
#: src/pages/UserPublicProfile.tsx:166
|
#: src/pages/UserPublicProfile.tsx:166
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
@@ -1670,7 +1671,7 @@ msgstr "Save"
|
|||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Saving…"
|
msgstr "Saving…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:177
|
#: src/components/layout/AppHeader.tsx:181
|
||||||
#: src/components/layout/SearchBar.tsx:83
|
#: src/components/layout/SearchBar.tsx:83
|
||||||
#: src/pages/Search.tsx:106
|
#: src/pages/Search.tsx:106
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
@@ -1718,7 +1719,7 @@ msgstr "Send reset link"
|
|||||||
msgid "Sending…"
|
msgid "Sending…"
|
||||||
msgstr "Sending…"
|
msgstr "Sending…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:193
|
#: src/components/layout/AppHeader.tsx:197
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "Server unreachable"
|
msgstr "Server unreachable"
|
||||||
|
|
||||||
@@ -1732,7 +1733,7 @@ msgstr "Set new password"
|
|||||||
msgid "email.settings"
|
msgid "email.settings"
|
||||||
msgstr "Settings"
|
msgstr "Settings"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:105
|
#: src/components/layout/UserMenu.tsx:109
|
||||||
#: src/pages/Settings.tsx:75
|
#: src/pages/Settings.tsx:75
|
||||||
#: src/pages/Settings.tsx:88
|
#: src/pages/Settings.tsx:88
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
@@ -1980,7 +1981,7 @@ msgstr "Unsubscribe"
|
|||||||
msgid "Unsubscribed"
|
msgid "Unsubscribed"
|
||||||
msgstr "Unsubscribed"
|
msgstr "Unsubscribed"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistSortTabs.tsx:25
|
#: src/components/playlist/PlaylistSortTabs.tsx:31
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "Updated"
|
msgstr "Updated"
|
||||||
|
|
||||||
@@ -2044,7 +2045,7 @@ msgstr "User"
|
|||||||
msgid "User management"
|
msgid "User management"
|
||||||
msgstr "User management"
|
msgstr "User management"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:64
|
#: src/components/layout/UserMenu.tsx:68
|
||||||
msgid "User menu"
|
msgid "User menu"
|
||||||
msgstr "User menu"
|
msgstr "User menu"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -450,7 +450,7 @@ msgstr "Annuler la suppression"
|
|||||||
msgid "Cancel reply"
|
msgid "Cancel reply"
|
||||||
msgstr "Annuler la réponse"
|
msgstr "Annuler la réponse"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:177
|
#: src/components/layout/AppHeader.tsx:181
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Annuler la recherche"
|
msgstr "Annuler la recherche"
|
||||||
|
|
||||||
@@ -579,7 +579,7 @@ msgstr "Impossible de changer le mot de passe"
|
|||||||
msgid "Could not connect to the server. Check your connection and try again."
|
msgid "Could not connect to the server. Check your connection and try again."
|
||||||
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
|
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:192
|
#: src/components/profile/ProfilePlaylistsTab.tsx:194
|
||||||
#: src/pages/DumpEdit.tsx:555
|
#: src/pages/DumpEdit.tsx:555
|
||||||
msgid "Could not delete"
|
msgid "Could not delete"
|
||||||
msgstr "Suppression impossible"
|
msgstr "Suppression impossible"
|
||||||
@@ -681,7 +681,7 @@ msgstr "Échec de la suppression ({0})"
|
|||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:162
|
#: src/components/playlist/PlaylistCard.tsx:162
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:222
|
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||||
#: src/pages/PlaylistDetail.tsx:172
|
#: src/pages/PlaylistDetail.tsx:172
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Supprimer la collection"
|
msgstr "Supprimer la collection"
|
||||||
@@ -698,7 +698,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible."
|
|||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Supprimer ce message ?"
|
msgstr "Supprimer ce message ?"
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:221
|
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||||
#: src/pages/PlaylistDetail.tsx:171
|
#: src/pages/PlaylistDetail.tsx:171
|
||||||
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."
|
||||||
@@ -753,7 +753,7 @@ msgstr "Déposez un fichier ici, ou collez-en un"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Déposez un fichier de remplacement ici"
|
msgstr "Déposez un fichier de remplacement ici"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:196
|
#: src/components/layout/AppHeader.tsx:200
|
||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Reco"
|
msgstr "Reco"
|
||||||
|
|
||||||
@@ -1114,7 +1114,7 @@ msgstr "Lien ou fichier"
|
|||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:229
|
#: src/components/layout/AppHeader.tsx:233
|
||||||
msgid "Live updates unavailable."
|
msgid "Live updates unavailable."
|
||||||
msgstr "Mises à jour en direct indisponibles."
|
msgstr "Mises à jour en direct indisponibles."
|
||||||
|
|
||||||
@@ -1159,7 +1159,7 @@ msgstr "Chargement du profil…"
|
|||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Chargement…"
|
msgstr "Chargement…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:203
|
#: src/components/layout/AppHeader.tsx:207
|
||||||
#: src/pages/FederationCallback.tsx:32
|
#: src/pages/FederationCallback.tsx:32
|
||||||
#: src/pages/UserLogin.tsx:41
|
#: src/pages/UserLogin.tsx:41
|
||||||
#: src/pages/UserLogin.tsx:66
|
#: src/pages/UserLogin.tsx:66
|
||||||
@@ -1180,7 +1180,7 @@ msgstr "Se connecter pour valider"
|
|||||||
msgid "Log in with {0}"
|
msgid "Log in with {0}"
|
||||||
msgstr "Se connecter avec {0}"
|
msgstr "Se connecter avec {0}"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:128
|
#: src/components/layout/UserMenu.tsx:132
|
||||||
#: src/pages/UserPublicProfile.tsx:137
|
#: src/pages/UserPublicProfile.tsx:137
|
||||||
msgid "Log out"
|
msgid "Log out"
|
||||||
msgstr "Se déconnecter"
|
msgstr "Se déconnecter"
|
||||||
@@ -1198,7 +1198,7 @@ msgstr "Connexion échouée"
|
|||||||
msgid "Lowercase letters, numbers and single hyphens only"
|
msgid "Lowercase letters, numbers and single hyphens only"
|
||||||
msgstr "Minuscules, chiffres et tirets simples seulement"
|
msgstr "Minuscules, chiffres et tirets simples seulement"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:115
|
#: src/components/layout/UserMenu.tsx:119
|
||||||
#: src/pages/Manage.tsx:32
|
#: src/pages/Manage.tsx:32
|
||||||
#: src/pages/Manage.tsx:40
|
#: src/pages/Manage.tsx:40
|
||||||
#: src/pages/UserPublicProfile.tsx:170
|
#: src/pages/UserPublicProfile.tsx:170
|
||||||
@@ -1209,6 +1209,10 @@ msgstr "Administration"
|
|||||||
msgid "Max {limit}"
|
msgid "Max {limit}"
|
||||||
msgstr "Max {limit}"
|
msgstr "Max {limit}"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:34
|
||||||
|
msgid "Mine"
|
||||||
|
msgstr "Les miennes"
|
||||||
|
|
||||||
#: src/components/profile/RoleSwitcher.tsx:23
|
#: src/components/profile/RoleSwitcher.tsx:23
|
||||||
msgid "Moderator"
|
msgid "Moderator"
|
||||||
msgstr "Modérateur"
|
msgstr "Modérateur"
|
||||||
@@ -1218,7 +1222,7 @@ msgstr "Modérateur"
|
|||||||
msgid "digest.monthly"
|
msgid "digest.monthly"
|
||||||
msgstr "Récap mensuel"
|
msgstr "Récap mensuel"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistSortTabs.tsx:27
|
#: src/components/playlist/PlaylistSortTabs.tsx:33
|
||||||
msgid "Most dumps"
|
msgid "Most dumps"
|
||||||
msgstr "Les plus fournies"
|
msgstr "Les plus fournies"
|
||||||
|
|
||||||
@@ -1226,10 +1230,6 @@ msgstr "Les plus fournies"
|
|||||||
msgid "Mute"
|
msgid "Mute"
|
||||||
msgstr "Couper le son"
|
msgstr "Couper le son"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:89
|
|
||||||
msgid "My playlists"
|
|
||||||
msgstr "Mes collections"
|
|
||||||
|
|
||||||
#: src/components/category/CategoryManager.tsx:208
|
#: src/components/category/CategoryManager.tsx:208
|
||||||
#: src/components/category/CategoryManager.tsx:209
|
#: src/components/category/CategoryManager.tsx:209
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
@@ -1237,7 +1237,7 @@ msgstr "Nom"
|
|||||||
|
|
||||||
#: src/components/chat/ChatModal.tsx:692
|
#: src/components/chat/ChatModal.tsx:692
|
||||||
#: src/components/feed/FeedTabBar.tsx:58
|
#: src/components/feed/FeedTabBar.tsx:58
|
||||||
#: src/components/playlist/PlaylistSortTabs.tsx:26
|
#: src/components/playlist/PlaylistSortTabs.tsx:32
|
||||||
#: src/pages/Settings.tsx:55
|
#: src/pages/Settings.tsx:55
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "Nouveau"
|
msgstr "Nouveau"
|
||||||
@@ -1315,8 +1315,8 @@ msgid "No playlists match \"{q}\"."
|
|||||||
msgstr "Aucune collection ne correspond à « {q} »."
|
msgstr "Aucune collection ne correspond à « {q} »."
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:199
|
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||||
#: src/pages/Playlists.tsx:151
|
#: src/pages/Playlists.tsx:180
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "Pas encore de collections."
|
msgstr "Pas encore de collections."
|
||||||
|
|
||||||
@@ -1477,11 +1477,12 @@ msgid "Playlist categories"
|
|||||||
msgstr "Catégories de collections"
|
msgstr "Catégories de collections"
|
||||||
|
|
||||||
#: src/components/dump/create/StepIndicator.tsx:21
|
#: src/components/dump/create/StepIndicator.tsx:21
|
||||||
#: src/components/layout/AppHeader.tsx:169
|
#: src/components/layout/AppHeader.tsx:173
|
||||||
|
#: src/components/layout/UserMenu.tsx:93
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:147
|
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:161
|
#: src/components/profile/ProfilePlaylistsTab.tsx:162
|
||||||
#: src/pages/Playlists.tsx:60
|
#: src/pages/Playlists.tsx:74
|
||||||
#: src/pages/Playlists.tsx:132
|
#: src/pages/Playlists.tsx:160
|
||||||
#: src/pages/Search.tsx:214
|
#: src/pages/Search.tsx:214
|
||||||
#: src/pages/UserPublicProfile.tsx:166
|
#: src/pages/UserPublicProfile.tsx:166
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
@@ -1670,7 +1671,7 @@ msgstr "Enregistrer"
|
|||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Enregistrement…"
|
msgstr "Enregistrement…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:177
|
#: src/components/layout/AppHeader.tsx:181
|
||||||
#: src/components/layout/SearchBar.tsx:83
|
#: src/components/layout/SearchBar.tsx:83
|
||||||
#: src/pages/Search.tsx:106
|
#: src/pages/Search.tsx:106
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
@@ -1718,7 +1719,7 @@ msgstr "Envoyer le lien de réinitialisation"
|
|||||||
msgid "Sending…"
|
msgid "Sending…"
|
||||||
msgstr "Envoi…"
|
msgstr "Envoi…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:193
|
#: src/components/layout/AppHeader.tsx:197
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "Serveur inaccessible"
|
msgstr "Serveur inaccessible"
|
||||||
|
|
||||||
@@ -1732,7 +1733,7 @@ msgstr "Définir un nouveau mot de passe"
|
|||||||
msgid "email.settings"
|
msgid "email.settings"
|
||||||
msgstr "Paramètres"
|
msgstr "Paramètres"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:105
|
#: src/components/layout/UserMenu.tsx:109
|
||||||
#: src/pages/Settings.tsx:75
|
#: src/pages/Settings.tsx:75
|
||||||
#: src/pages/Settings.tsx:88
|
#: src/pages/Settings.tsx:88
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
@@ -1980,7 +1981,7 @@ msgstr "Se désabonner"
|
|||||||
msgid "Unsubscribed"
|
msgid "Unsubscribed"
|
||||||
msgstr "Désabonné"
|
msgstr "Désabonné"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistSortTabs.tsx:25
|
#: src/components/playlist/PlaylistSortTabs.tsx:31
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "Mises à jour"
|
msgstr "Mises à jour"
|
||||||
|
|
||||||
@@ -2044,7 +2045,7 @@ msgstr "Utilisateur"
|
|||||||
msgid "User management"
|
msgid "User management"
|
||||||
msgstr "Gestion utilisateur"
|
msgstr "Gestion utilisateur"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:64
|
#: src/components/layout/UserMenu.tsx:68
|
||||||
msgid "User menu"
|
msgid "User menu"
|
||||||
msgstr "Menu utilisateur"
|
msgstr "Menu utilisateur"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* view is the bare path. A category narrowing a list on another page, a
|
* view is the bare path. A category narrowing a list on another page, a
|
||||||
* profile's or a search's, is a query, `?category=`, not a segment, and so is
|
* profile's or a search's, is a query, `?category=`, not a segment, and so is
|
||||||
* the order of a list of playlists, `?sort=`, the default order being the
|
* the order of a list of playlists, `?sort=`, the default order being the
|
||||||
* bare path.
|
* bare path, and the playlists page's list of the viewer's own, `?mine=1`.
|
||||||
*
|
*
|
||||||
* The client links every page through the builders (`src/utils/shared/urls.ts`
|
* The client links every page through the builders (`src/utils/shared/urls.ts`
|
||||||
* wraps the dump and playlist ones), the emails prefix them with the
|
* wraps the dump and playlist ones), the emails prefix them with the
|
||||||
@@ -21,7 +21,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CategoryKind } from "./category.ts";
|
import type { CategoryKind } from "./category.ts";
|
||||||
import { PLAYLIST_SORTS, type PlaylistSort } from "./playlist.ts";
|
import {
|
||||||
|
parsePlaylistSort,
|
||||||
|
PLAYLIST_SORTS,
|
||||||
|
type PlaylistSort,
|
||||||
|
} from "./playlist.ts";
|
||||||
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
|
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
|
||||||
import { HANDLE_RE, splitHandle } from "./validation.ts";
|
import { HANDLE_RE, splitHandle } from "./validation.ts";
|
||||||
|
|
||||||
@@ -67,6 +71,15 @@ export const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
|
|||||||
/** One of the {@link SEARCH_TABS}. */
|
/** One of the {@link SEARCH_TABS}. */
|
||||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tabs of the playlists page, in the tab bar's order: each order of every
|
||||||
|
* public playlist, `?sort=`, then the viewer's own playlists, `?mine=1`,
|
||||||
|
* which only a signed-in user is offered.
|
||||||
|
*/
|
||||||
|
export const PLAYLISTS_TABS = [...PLAYLIST_SORTS, "mine"] as const;
|
||||||
|
/** One of the {@link PLAYLISTS_TABS}. */
|
||||||
|
export type PlaylistsTab = (typeof PLAYLISTS_TABS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The views of the community page, one per digest cadence: the week at
|
* The views of the community page, one per digest cadence: the week at
|
||||||
* `/community`, the month at `/community/monthly`.
|
* `/community`, the month at `/community/monthly`.
|
||||||
@@ -145,15 +158,24 @@ export function feedPath(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the path of the page of every public playlist, `/playlists`, or of a
|
* Returns the path of the page of every public playlist, `/playlists`, or of a
|
||||||
* playlist category's at `/playlists/<slug>`, in the order `sort` names, the
|
* playlist category's at `/playlists/<slug>`, on the tab `tab` names: an
|
||||||
* default one without it.
|
* order, the default one without it, or the viewer's own playlists.
|
||||||
*/
|
*/
|
||||||
export function playlistsPath(
|
export function playlistsPath(
|
||||||
categorySlug?: string,
|
categorySlug?: string,
|
||||||
sort?: PlaylistSort,
|
tab?: PlaylistsTab,
|
||||||
): string {
|
): string {
|
||||||
const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
|
const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
|
||||||
return `${path}${playlistSortQuery(sort)}`;
|
return `${path}${tab === "mine" ? "?mine=1" : playlistSortQuery(tab)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tab of the playlists page its query names: the viewer's own
|
||||||
|
* playlists with a `mine`, else the order `sort` names, as
|
||||||
|
* `parsePlaylistSort` reads it.
|
||||||
|
*/
|
||||||
|
export function parsePlaylistsTab(params: URLSearchParams): PlaylistsTab {
|
||||||
|
return params.has("mine") ? "mine" : parsePlaylistSort(params.get("sort"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
dumpPath,
|
dumpPath,
|
||||||
feedPath,
|
feedPath,
|
||||||
parsePagePath,
|
parsePagePath,
|
||||||
|
parsePlaylistsTab,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
playlistSortQuery,
|
playlistSortQuery,
|
||||||
playlistsPath,
|
playlistsPath,
|
||||||
@@ -43,6 +44,14 @@ Deno.test("builders: one hierarchy, default views bare", () => {
|
|||||||
assertEquals(playlistsPath("mixes"), "/playlists/mixes");
|
assertEquals(playlistsPath("mixes"), "/playlists/mixes");
|
||||||
assertEquals(playlistsPath(undefined, "updated"), "/playlists");
|
assertEquals(playlistsPath(undefined, "updated"), "/playlists");
|
||||||
assertEquals(playlistsPath("mixes", "new"), "/playlists/mixes?sort=new");
|
assertEquals(playlistsPath("mixes", "new"), "/playlists/mixes?sort=new");
|
||||||
|
assertEquals(playlistsPath("mixes", "mine"), "/playlists/mixes?mine=1");
|
||||||
|
// The page's tab is one or the other: its own playlists come in one order.
|
||||||
|
const tab = (query: string) => parsePlaylistsTab(new URLSearchParams(query));
|
||||||
|
assertEquals(tab(""), "updated");
|
||||||
|
assertEquals(tab("sort=dumps"), "dumps");
|
||||||
|
assertEquals(tab("sort=nope"), "updated");
|
||||||
|
assertEquals(tab("mine=1"), "mine");
|
||||||
|
assertEquals(tab("mine=1&sort=dumps"), "mine");
|
||||||
// The query of a list that has other parameters: the default order takes
|
// The query of a list that has other parameters: the default order takes
|
||||||
// an old `sort` out, and leaves the rest.
|
// an old `sort` out, and leaves the rest.
|
||||||
const category = new URLSearchParams({ category: "mixes", sort: "new" });
|
const category = new URLSearchParams({ category: "mixes", sort: "new" });
|
||||||
|
|||||||
33
src/App.css
33
src/App.css
@@ -3115,16 +3115,39 @@ body.has-player .chat-fab {
|
|||||||
--nav-icon-tint-strength: 100%;
|
--nav-icon-tint-strength: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text links — visible only at wide viewports */
|
/* The text link to every playlist. A guest's header holds no other, so
|
||||||
|
theirs shows at every width. Signed in, the avatar's menu holds one too,
|
||||||
|
and this one shows where the row has room for it beside the icons: from
|
||||||
|
560px, where it fits with the "+ Dump" label in every style and language,
|
||||||
|
up to 860px, where the index page's tabs move into the header, and again
|
||||||
|
from 1150px, where the header holds both. */
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: none;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1150px) {
|
@media (max-width: 559px), (min-width: 860px) and (max-width: 1149px) {
|
||||||
.nav-links {
|
.app-header:not(.app-header--guest) .nav-links {
|
||||||
display: flex;
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A guest's row is the link, the search and "Log in": on the narrowest
|
||||||
|
phones it gives up its margins rather than the link. */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.app-header--guest {
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header--guest .app-header-nav {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header--guest .app-header-nav button,
|
||||||
|
.app-header--guest .app-header-user {
|
||||||
|
padding-inline: 0.6rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
* - `/community` and `/community/monthly`, the site's week and month
|
* - `/community` and `/community/monthly`, the site's week and month
|
||||||
* (`Community`), signed in only.
|
* (`Community`), signed in only.
|
||||||
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
|
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
|
||||||
* playlist category's, in the order `?sort=` names (`Playlists`).
|
* playlist category's, in the order `?sort=` names, or with `?mine=1` the
|
||||||
|
* signed-in user's own (`Playlists`).
|
||||||
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
|
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
|
||||||
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
|
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
|
||||||
* signed in only), and their playlists at `playlists/:slug`
|
* signed in only), and their playlists at `playlists/:slug`
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ export interface AppHeaderProps {
|
|||||||
/**
|
/**
|
||||||
* Renders the site's header. It holds the search, which stays open on the
|
* Renders the site's header. It holds the search, which stays open on the
|
||||||
* results page and slides open under the header on small screens, the link to
|
* results page and slides open under the header on small screens, the link to
|
||||||
* every playlist, the signed-in user's menu and buttons or a guest's "Log in",
|
* the playlists page, the signed-in user's menu and buttons or a guest's
|
||||||
* and a banner while live updates are down. It also hosts the dump creation
|
* "Log in", and a banner while live updates are down. It also hosts the dump
|
||||||
* dialog that its "+ Dump" button opens.
|
* creation dialog that its "+ Dump" button opens.
|
||||||
*/
|
*/
|
||||||
export function AppHeader(
|
export function AppHeader(
|
||||||
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
|
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
|
||||||
@@ -143,7 +143,11 @@ export function AppHeader(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="app-header app-header--has-center">
|
<header
|
||||||
|
className={`app-header app-header--has-center${
|
||||||
|
user ? "" : " app-header--guest"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Link to="/" className="app-header-brand">
|
<Link to="/" className="app-header-brand">
|
||||||
{SITE_EMOJI}
|
{SITE_EMOJI}
|
||||||
<span className="app-header-brand-name">
|
<span className="app-header-brand-name">
|
||||||
@@ -160,9 +164,9 @@ export function AppHeader(
|
|||||||
<nav className="app-header-nav">
|
<nav className="app-header-nav">
|
||||||
{
|
{
|
||||||
/* The links lead the row, so the icons after them run unbroken to
|
/* The links lead the row, so the icons after them run unbroken to
|
||||||
the "+": every playlist on wide screens, for guests too, then
|
the "+": the playlists page, at every width for a guest and
|
||||||
the avatar's menu, which leads to the user's profile and their
|
where the row has room for a signed-in user, then the avatar's
|
||||||
own playlists, at every width. */
|
menu, which leads there too, at every width. */
|
||||||
}
|
}
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<Link to={playlistsPath()} className="app-header-user">
|
<Link to={playlistsPath()} className="app-header-user">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Renders the avatar menu that leads the header's icon row at every width: it
|
* Renders the avatar menu that leads the header's icon row at every width: it
|
||||||
* holds the profile link, the user's own playlists (the header's text link
|
* holds the profile link, the playlists page (the header's text link leads
|
||||||
* leads to everyone's), the community page, the settings, the site's tools, and
|
* there too, at the widths that have room for it), the community page, the
|
||||||
* logging out.
|
* settings, the site's tools, and logging out.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -16,7 +16,11 @@ import { DIGEST_MESSAGES } from "../../../shared/digest-messages.ts";
|
|||||||
import { Avatar } from "../shared/Avatar.tsx";
|
import { Avatar } from "../shared/Avatar.tsx";
|
||||||
import type { SelfUser } from "../../../shared/user.ts";
|
import type { SelfUser } from "../../../shared/user.ts";
|
||||||
import { can } from "../../utils/auth/permissions.ts";
|
import { can } from "../../utils/auth/permissions.ts";
|
||||||
import { communityPath, userPath } from "../../../shared/paths.ts";
|
import {
|
||||||
|
communityPath,
|
||||||
|
playlistsPath,
|
||||||
|
userPath,
|
||||||
|
} from "../../../shared/paths.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
|
|
||||||
/** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */
|
/** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */
|
||||||
@@ -26,7 +30,7 @@ export interface UserMenuProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the signed-in user's avatar, which opens a menu with links to their
|
* Renders the signed-in user's avatar, which opens a menu with links to their
|
||||||
* profile, their playlists, `/community` and `/settings`, to `/manage` for
|
* profile, `/playlists`, `/community` and `/settings`, to `/manage` for
|
||||||
* those who hold `category:manage` or `federation:manage`, and a Log out
|
* those who hold `category:manage` or `federation:manage`, and a Log out
|
||||||
* button. A click outside or Escape closes it.
|
* button. A click outside or Escape closes it.
|
||||||
*/
|
*/
|
||||||
@@ -81,12 +85,12 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
|||||||
@{user.username}
|
@{user.username}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={userPath(user.username, "playlists")}
|
to={playlistsPath()}
|
||||||
className="user-menu-item"
|
className="user-menu-item"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
<Trans>My playlists</Trans>
|
<Trans>Playlists</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={communityPath()}
|
to={communityPath()}
|
||||||
|
|||||||
@@ -1,39 +1,46 @@
|
|||||||
/**
|
/**
|
||||||
* Renders the tabs that put a list of playlists in another order, on the
|
* Renders the tabs that put a list of playlists in another order, on the
|
||||||
* playlists page and on a profile's playlists tab.
|
* playlists page and on a profile's playlists tab, and the playlists page's
|
||||||
|
* tab of the viewer's own.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { PLAYLIST_SORTS, type PlaylistSort } from "../../../shared/playlist.ts";
|
import type { PlaylistsTab } from "../../../shared/paths.ts";
|
||||||
import { TabBar } from "../shared/TabBar.tsx";
|
import { TabBar } from "../shared/TabBar.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passed to {@link PlaylistSortTabs} by `Playlists` and
|
* Passed to {@link PlaylistSortTabs} by `ProfilePlaylistsTab`, whose tabs
|
||||||
* `ProfilePlaylistsTab`, which keep the order in their URL's `?sort=`.
|
* are `PLAYLIST_SORTS`, and by `Playlists`, whose `PLAYLISTS_TABS` add the
|
||||||
|
* viewer's own playlists for a signed-in user. Both keep the active one in
|
||||||
|
* their URL's query.
|
||||||
*/
|
*/
|
||||||
export interface PlaylistSortTabsProps {
|
export interface PlaylistSortTabsProps<T extends PlaylistsTab> {
|
||||||
sort: PlaylistSort;
|
/** The tabs offered, in order. */
|
||||||
onChange: (sort: PlaylistSort) => void;
|
tabs: readonly T[];
|
||||||
|
/** The active tab. */
|
||||||
|
sort: T;
|
||||||
|
onChange: (sort: T) => void;
|
||||||
/** Goes before the tabs, as the playlists page's category menu does. */
|
/** Goes before the tabs, as the playlists page's category menu does. */
|
||||||
leading?: ReactNode;
|
leading?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABELS: Record<PlaylistSort, ReactNode> = {
|
const LABELS: Record<PlaylistsTab, ReactNode> = {
|
||||||
updated: <Trans>Updated</Trans>,
|
updated: <Trans>Updated</Trans>,
|
||||||
new: <Trans>New</Trans>,
|
new: <Trans>New</Trans>,
|
||||||
dumps: <Trans>Most dumps</Trans>,
|
dumps: <Trans>Most dumps</Trans>,
|
||||||
|
mine: <Trans>Mine</Trans>,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Renders one tab per order of `PLAYLIST_SORTS`, `sort` being the active one. */
|
/** Renders one tab per entry of `tabs`, `sort` being the active one. */
|
||||||
export function PlaylistSortTabs(
|
export function PlaylistSortTabs<T extends PlaylistsTab>(
|
||||||
{ sort, onChange, leading }: PlaylistSortTabsProps,
|
{ tabs, sort, onChange, leading }: PlaylistSortTabsProps<T>,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={PLAYLIST_SORTS.map((key) => ({ key, label: LABELS[key] }))}
|
tabs={tabs.map((key) => ({ key, label: LABELS[key] }))}
|
||||||
activeTab={sort}
|
activeTab={sort}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
leading={leading}
|
leading={leading}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { userApiUrl } from "../../utils/shared/urls.ts";
|
|||||||
import {
|
import {
|
||||||
parsePlaylistSort,
|
parsePlaylistSort,
|
||||||
type Playlist,
|
type Playlist,
|
||||||
|
PLAYLIST_SORTS,
|
||||||
type PlaylistSort,
|
type PlaylistSort,
|
||||||
} from "../../../shared/playlist.ts";
|
} from "../../../shared/playlist.ts";
|
||||||
import { playlistSortQuery } from "../../../shared/paths.ts";
|
import { playlistSortQuery } from "../../../shared/paths.ts";
|
||||||
@@ -162,6 +163,7 @@ function PlaylistList(
|
|||||||
actions={
|
actions={
|
||||||
<div className="profile-section-actions">
|
<div className="profile-section-actions">
|
||||||
<PlaylistSortTabs
|
<PlaylistSortTabs
|
||||||
|
tabs={PLAYLIST_SORTS}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
// Another order of the same list, as a tab is: it takes the
|
// Another order of the same list, as a tab is: it takes the
|
||||||
// current history entry's place.
|
// current history entry's place.
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* Renders every public playlist, `/playlists`, and those of one playlist
|
* Renders every public playlist, `/playlists`, and those of one playlist
|
||||||
* category, `/playlists/:categorySlug`, in the order `?sort=` names.
|
* category, `/playlists/:categorySlug`, in the order `?sort=` names, or with
|
||||||
|
* `?mine=1` the signed-in user's own. The header's link and the avatar
|
||||||
|
* menu's lead here.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
import {
|
||||||
|
Navigate,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
useSearchParams,
|
||||||
|
} 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 { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import { type Playlist, PLAYLIST_SORTS } from "../../shared/playlist.ts";
|
||||||
import {
|
import {
|
||||||
parsePlaylistSort,
|
parsePlaylistsTab,
|
||||||
type Playlist,
|
PLAYLISTS_TABS,
|
||||||
type PlaylistSort,
|
playlistSortQuery,
|
||||||
} from "../../shared/playlist.ts";
|
playlistsPath,
|
||||||
import { playlistSortQuery, playlistsPath } from "../../shared/paths.ts";
|
type PlaylistsTab,
|
||||||
|
} from "../../shared/paths.ts";
|
||||||
|
import { loginPath } from "../utils/auth/next.ts";
|
||||||
|
import { userApiUrl } from "../utils/shared/urls.ts";
|
||||||
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||||
import {
|
import {
|
||||||
deserializePlaylist,
|
deserializePlaylist,
|
||||||
@@ -45,14 +57,16 @@ import { NotFound } from "./NotFound.tsx";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the list for the route's category, or every public playlist
|
* Renders the list for the route's category, or every public playlist
|
||||||
* without one, in the order of the URL's `?sort=`. Each category's list in
|
* without one, on the tab the URL's query names. Each category's list on
|
||||||
* each order is another, which the page mounts afresh. A slug that names no
|
* each tab is another, which the page mounts afresh. A slug that names no
|
||||||
* playlist category, once the categories have loaded, is a page not found.
|
* playlist category, once the categories have loaded, is a page not found.
|
||||||
*/
|
*/
|
||||||
export function Playlists(): React.JSX.Element {
|
export function Playlists(): React.JSX.Element {
|
||||||
const { categorySlug } = useParams();
|
const { categorySlug } = useParams();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const sort = parsePlaylistSort(params.get("sort"));
|
const location = useLocation();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const tab = parsePlaylistsTab(params);
|
||||||
const { categories, isLoaded } = useCategories("playlist");
|
const { categories, isLoaded } = useCategories("playlist");
|
||||||
const category = categorySlug
|
const category = categorySlug
|
||||||
? categories.find((c) => c.slug === categorySlug)
|
? categories.find((c) => c.slug === categorySlug)
|
||||||
@@ -60,12 +74,19 @@ export function Playlists(): React.JSX.Element {
|
|||||||
useDocumentTitle(category ? category.name : t`Playlists`);
|
useDocumentTitle(category ? category.name : t`Playlists`);
|
||||||
|
|
||||||
if (categorySlug && isLoaded && !category) return <NotFound />;
|
if (categorySlug && isLoaded && !category) return <NotFound />;
|
||||||
|
// A guest isn't offered the tab of their own playlists, so only a link
|
||||||
|
// leads one there, or signing out on it.
|
||||||
|
if (tab === "mine" && !user) {
|
||||||
|
return (
|
||||||
|
<Navigate to={loginPath(location.pathname + location.search)} replace />
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<PlaylistsList
|
<PlaylistsList
|
||||||
key={`${categorySlug ?? ""}:${sort}`}
|
key={`${categorySlug ?? ""}:${tab}`}
|
||||||
categorySlug={categorySlug}
|
categorySlug={categorySlug}
|
||||||
categoryId={category?.id}
|
categoryId={category?.id}
|
||||||
sort={sort}
|
tab={tab}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,25 +95,32 @@ export function Playlists(): React.JSX.Element {
|
|||||||
* Renders the public playlists, from `GET /api/playlists`, as a grid of
|
* Renders the public playlists, from `GET /api/playlists`, as a grid of
|
||||||
* covers, a page at a time as the list scrolls; with `categorySlug`, only
|
* covers, a page at a time as the list scrolls; with `categorySlug`, only
|
||||||
* those in that playlist category, which the menu before the tabs switches,
|
* those in that playlist category, which the menu before the tabs switches,
|
||||||
* keeping the order, while a tab changes the order in place of the current
|
* keeping the tab, while a tab changes the order in place of the current
|
||||||
* history entry, as a feed's tabs do. The socket keeps them live, a
|
* history entry, as a feed's tabs do. A signed-in user's last tab lists
|
||||||
* reconnection fetches the pages loaded again, and going back restores the
|
* their own instead, the private ones too, from
|
||||||
* list and the scroll.
|
* `GET /api/users/:userId/playlists` in the default order. The socket keeps
|
||||||
|
* them live, a reconnection fetches the pages loaded again, and going back
|
||||||
|
* restores the list and the scroll.
|
||||||
*/
|
*/
|
||||||
function PlaylistsList(
|
function PlaylistsList(
|
||||||
{ categorySlug, categoryId, sort }: {
|
{ categorySlug, categoryId, tab }: {
|
||||||
categorySlug?: string;
|
categorySlug?: string;
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
sort: PlaylistSort;
|
tab: PlaylistsTab;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, authFetch } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
|
// `Playlists` shows the tab to no guest.
|
||||||
|
const owner = tab === "mine" && user ? user : undefined;
|
||||||
|
const sort = tab === "mine" ? PLAYLIST_SORTS[0] : tab;
|
||||||
const { cached, saveState } = useFeedCache<Playlist>(
|
const { cached, saveState } = useFeedCache<Playlist>(
|
||||||
`feed:playlists:${categorySlug ?? "all"}:${sort}`,
|
`feed:playlists:${categorySlug ?? "all"}:${tab}`,
|
||||||
hydratePlaylist,
|
hydratePlaylist,
|
||||||
);
|
);
|
||||||
const url = `${API_URL}/api/playlists${
|
const url = `${
|
||||||
|
owner ? userApiUrl(owner, "playlists") : `${API_URL}/api/playlists`
|
||||||
|
}${
|
||||||
playlistSortQuery(
|
playlistSortQuery(
|
||||||
sort,
|
sort,
|
||||||
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
|
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
|
||||||
@@ -114,7 +142,7 @@ function PlaylistsList(
|
|||||||
usePlaylistListSync(
|
usePlaylistListSync(
|
||||||
loaded?.items ?? [],
|
loaded?.items ?? [],
|
||||||
useCategoryFilter(setAllItems, categoryId),
|
useCategoryFilter(setAllItems, categoryId),
|
||||||
{ sort },
|
{ sort, isOwner: !!owner, ownerId: owner?.id },
|
||||||
);
|
);
|
||||||
useOnReconnect(resync);
|
useOnReconnect(resync);
|
||||||
|
|
||||||
@@ -131,14 +159,15 @@ function PlaylistsList(
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
|
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
|
||||||
<PlaylistSortTabs
|
<PlaylistSortTabs
|
||||||
sort={sort}
|
tabs={user ? PLAYLISTS_TABS : PLAYLIST_SORTS}
|
||||||
onChange={(s) =>
|
sort={tab}
|
||||||
navigate(playlistsPath(categorySlug, s), { replace: true })}
|
onChange={(next) =>
|
||||||
|
navigate(playlistsPath(categorySlug, next), { replace: true })}
|
||||||
leading={
|
leading={
|
||||||
<CategorySwitcher
|
<CategorySwitcher
|
||||||
kind="playlist"
|
kind="playlist"
|
||||||
categorySlug={categorySlug}
|
categorySlug={categorySlug}
|
||||||
onSelect={(slug) => navigate(playlistsPath(slug, sort))}
|
onSelect={(slug) => navigate(playlistsPath(slug, tab))}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,13 +22,10 @@ describe("UserMenu", () => {
|
|||||||
"href",
|
"href",
|
||||||
"/@ann",
|
"/@ann",
|
||||||
);
|
);
|
||||||
// One playlists link, the user's own: the header's leads to everyone's.
|
// One playlists link, to the page of everyone's, whose last tab is the
|
||||||
expect(screen.getAllByRole("menuitem", { name: /playlists/i }))
|
// user's own: the header's text link is hidden where it has no room.
|
||||||
.toHaveLength(
|
expect(screen.getByRole("menuitem", { name: "Playlists" }))
|
||||||
1,
|
.toHaveAttribute("href", "/playlists");
|
||||||
);
|
|
||||||
expect(screen.getByRole("menuitem", { name: "My playlists" }))
|
|
||||||
.toHaveAttribute("href", "/@ann/playlists");
|
|
||||||
expect(screen.getByRole("menuitem", { name: "Community" })).toHaveAttribute(
|
expect(screen.getByRole("menuitem", { name: "Community" })).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
"/community",
|
"/community",
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { Route } from "react-router";
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { Playlists } from "../../pages/Playlists.tsx";
|
import { Playlists } from "../../pages/Playlists.tsx";
|
||||||
import { calls, route } from "../fetch.ts";
|
import { calls, route } from "../fetch.ts";
|
||||||
import { makeCategory, makePlaylist, page, wire } from "../fixtures.ts";
|
import {
|
||||||
|
makeCategory,
|
||||||
|
makePlaylist,
|
||||||
|
makeSession,
|
||||||
|
page,
|
||||||
|
wire,
|
||||||
|
} from "../fixtures.ts";
|
||||||
|
import type { Session } from "../../utils/auth/storage.ts";
|
||||||
import { currentPath, renderApp } from "../render.tsx";
|
import { currentPath, renderApp } from "../render.tsx";
|
||||||
import { FakeWebSocket } from "../ws.ts";
|
import { FakeWebSocket } from "../ws.ts";
|
||||||
|
|
||||||
@@ -15,12 +23,14 @@ const mixes = makeCategory({
|
|||||||
});
|
});
|
||||||
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
|
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
|
||||||
|
|
||||||
function open(url: string) {
|
function open(url: string, session?: Session) {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderApp(<Playlists />, {
|
renderApp(<Playlists />, {
|
||||||
route: url,
|
route: url,
|
||||||
path: "/playlists/:categorySlug?",
|
path: "/playlists/:categorySlug?",
|
||||||
categories: [music, mixes],
|
categories: [music, mixes],
|
||||||
|
session,
|
||||||
|
extraRoutes: <Route path="/login" element={null} />,
|
||||||
});
|
});
|
||||||
return { user, socket: FakeWebSocket.last };
|
return { user, socket: FakeWebSocket.last };
|
||||||
}
|
}
|
||||||
@@ -140,6 +150,77 @@ describe("Playlists", () => {
|
|||||||
expect(screen.getByText("2 dumps")).toBeInTheDocument();
|
expect(screen.getByText("2 dumps")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a signed-in user's last tab lists their own, private ones too", async () => {
|
||||||
|
const session = makeSession({ id: "ann-id", username: "ann" });
|
||||||
|
route(
|
||||||
|
"GET /api/playlists",
|
||||||
|
() => page([makePlaylist({ title: "Theirs" })]),
|
||||||
|
);
|
||||||
|
route(
|
||||||
|
"GET /api/users/:u/playlists",
|
||||||
|
() =>
|
||||||
|
page([
|
||||||
|
makePlaylist({ title: "Hidden", userId: "ann-id", isPublic: false }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const { user, socket } = open("/playlists/mixes?sort=dumps", session);
|
||||||
|
expect(await screen.findByText("Theirs")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Their own come in one order: the tab takes the order's place.
|
||||||
|
await user.click(screen.getByRole("button", { name: "Mine" }));
|
||||||
|
expect(currentPath()).toBe("/playlists/mixes?mine=1");
|
||||||
|
expect(await screen.findByText("Hidden")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Theirs")).toBeNull();
|
||||||
|
const asked = calls("GET /api/users/:u/playlists").at(-1)!.url;
|
||||||
|
expect(asked.pathname).toBe("/api/users/ann-id/playlists");
|
||||||
|
expect([asked.searchParams.get("category"), asked.searchParams.get("sort")])
|
||||||
|
.toEqual(["mixes", null]);
|
||||||
|
|
||||||
|
// The socket brings their new ones, private or not, and no one else's.
|
||||||
|
socket.welcome();
|
||||||
|
for (
|
||||||
|
const playlist of [
|
||||||
|
makePlaylist({
|
||||||
|
title: "Mine too",
|
||||||
|
userId: "ann-id",
|
||||||
|
isPublic: false,
|
||||||
|
categoryIds: ["pc1"],
|
||||||
|
}),
|
||||||
|
makePlaylist({
|
||||||
|
title: "Bob's",
|
||||||
|
userId: "bob-id",
|
||||||
|
categoryIds: ["pc1"],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
socket.receive({ type: "playlist_created", playlist: wire(playlist) });
|
||||||
|
}
|
||||||
|
expect(screen.getByText("Mine too")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Bob's")).toBeNull();
|
||||||
|
|
||||||
|
// Picking a category keeps the tab, an order leaves it.
|
||||||
|
await user.click(screen.getByRole("button", { name: "Mixes" }));
|
||||||
|
await user.click(screen.getByRole("option", { name: "All" }));
|
||||||
|
expect(currentPath()).toBe("/playlists?mine=1");
|
||||||
|
await user.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
expect(currentPath()).toBe("/playlists?sort=new");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a guest has no such tab", async () => {
|
||||||
|
route(
|
||||||
|
"GET /api/playlists",
|
||||||
|
() => page([makePlaylist({ title: "Theirs" })]),
|
||||||
|
);
|
||||||
|
open("/playlists");
|
||||||
|
expect(await screen.findByText("Theirs")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Mine" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a guest sent to their own playlists signs in first", () => {
|
||||||
|
open("/playlists?mine=1");
|
||||||
|
expect(currentPath()).toBe("/login?next=%2Fplaylists%3Fmine%3D1");
|
||||||
|
});
|
||||||
|
|
||||||
test("a slug that names no playlist category is not found", async () => {
|
test("a slug that names no playlist category is not found", async () => {
|
||||||
route("GET /api/playlists", () => page([]));
|
route("GET /api/playlists", () => page([]));
|
||||||
open("/playlists/music");
|
open("/playlists/music");
|
||||||
|
|||||||
Reference in New Issue
Block a user