Compare commits

...

22 Commits

Author SHA1 Message Date
khannurien
eec73e9749 v4: a comment and a chat message are edited in the same field as every text edited in place — each had an editor of its own: a comment's was its reply form, which showed a refused edit as a card and left the focus nowhere once closed, and a chat message's a box of its own with its own buttons and rules. InlineEdit gives its field, InlineEditField, to the components that open it with an action of their own, as these two do with their Edit and ✎: they keep those, a click on someone's message being no request to edit it. The field opens on the text with the caret at its end and as tall as the text, saves on Ctrl+Enter (and on Enter in the chat, where Shift+Enter breaks a line, as in its composer), leaves on Escape, sends nothing for a text left as it was, refuses an empty one, says why a save was refused in one line, and gives the focus back to the button that opened it, unless the caller has put it elsewhere, as the chat does to its composer. CommentForm only posts a comment or a reply now; ChatEditBox keeps only what is the chat's, scrolling itself into the list's view.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m20s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:16:43 +00:00
khannurien
3e03626cc1 v4: text is edited where it stands through one component, InlineEdit, and a dump's note and a playlist's title and description join its title, a profile's description and email — those three were written apart, each with its own state, keys, buttons, error and CSS, and the note and the playlist could only be changed in their full editors. InlineEdit (components/form) shows the text; a click on it, or the ✎ after it, opens a field holding it, with Save and Cancel under it. A one-line field takes the text's own type in every style, from the class it is handed (dump-title, playlist-detail-title), and sits on the faint plate the text showed under the pointer, so that nothing moves but the buttons that come; a Markdown text opens the editor comments use. Enter saves a line, Ctrl+Enter a Markdown text, Escape backs out, a text left as it was closes without a request, a required one can't be emptied, a refused save says why in one line beside the buttons, and the focus goes back to the ✎. A click on a link in the text, or one that ends a selection, follows the link or keeps the selection. The ✎ is now a button with the action's name, where a div pretended to be one, and the text keeps its own role, a heading or Markdown. The plate is drawn under the text by a pseudo-element, tinted from its colour: the surface colour it had was the card's own, and showed nothing on the dump page. On a playlist's page the title edits in place beside Follow and Edit, which go to the row's end, and the description too; an empty note or description says what goes there to whoever may write it. DescriptionEditor and EmailEditor go.
The GeoCities marquee stops only for a link the keyboard focused: a link pressed with the pointer took the focus as well, which put the line back at its start and scrolled it, so the link slipped from under the pointer before the button came up and the click was lost. The next page's marquee also carries on from the very point the last one had reached, with the loop it showed: it worked its place out from when the loop began, which ran on while the crawl was paused under the pointer, and showed the feed as it was by then, of another width.

The chat's message field rests on one line, as tall as the Send button: it rested on two, 54 to 62 pixels against the button's 38, and the button sat low beside it. Both take --chat-composer-height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:08:53 +00:00
khannurien
b92c674726 v4: the devcontainer has Playwright's browsers — they were installed by hand in the home directory, which the rebuild onto Node 26 left without them, and the screenshots of the dev instances stopped working. The Dockerfile installs Firefox and the Chromium headless shell of npm:playwright@1.63.0, with the system libraries they need, in /opt/ms-playwright (PLAYWRIGHT_BROWSERS_PATH), which the workspace and the instances' services share as part of the image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:08:53 +00:00
khannurien
7d0a0e7534 v4: the devcontainer runs Deno 2.9.7 on Node 26 — its image took Deno 2.9.0 and the Node 20 base. The Dockerfile now copies the deno binary from denoland/deno:bin-2.9.7 onto mcr.microsoft.com/devcontainers/typescript-node:26.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:38:16 +00:00
khannurien
51439f475d v4: the GeoCities marquee scrolls the community feed, what the site's members are doing — it crawled three canned lines, a welcome, "best viewed in Netscape Navigator" and "under construction", which said nothing of the site. The feed is a list any style may show (CommunityFeed.tsx), which GeoCities lays out along its marquee: who is online, the week's dumps, upvotes, comments and chat messages, linking to /community, its most upvoted dump, then the 12 latest things done in public, newest first, each ending on how long ago it was: a public dump posted, a public playlist created, a comment or an upvote on a public dump, someone joining and who invited them. Every name and title links to its page. A dump has one comment line and one upvote line at most, its latest, so that a thread or a popular dump doesn't fill the feed. GET /api/community/feed serves it, signed in only (community-service.ts): the events are read from the tables on each request, the week's figures are those /community already keeps, as of its last refresh. A signed-out visitor has no marquee. CommunityFeedProvider holds the feed around every page, so that it outlives the header each page renders anew, fetches it only while a component shows it, and fetches it again two seconds after the socket last told of a dump, a comment, a vote, a playlist, a rename, a deletion or a reconnection: the socket's messages don't carry a feed's lines, and the server's query stays the one place that decides what the feed holds. Someone joining comes with no message, and shows at the next of those. The crawl goes at 50 pixels a second however much the feed holds, Marquee.tsx measuring one copy for the stylesheet to work the loop's length out from, where a fixed 36 seconds would have sped up with every line. A loop shows what the feed held when it began: text that changed mid-crawl would jump, so what came meanwhile waits for the next loop, which also brings the times up to date. The next page's marquee carries on from where the last one's had got to, rather than from the start. The marquee was hidden from assistive technology as decoration; its first copy is now a labelled list of links, the second, which the seamless loop needs, hidden and out of the tab order. A link that takes the focus stops the crawl and the line scrolls to it, the pointer pauses it as before, and with reduced motion the line shows once, still, follows the feed as it changes and scrolls by hand. The sentences a notification also says, "posted", "upvoted", "commented on", share its translations. The tests' DOM gets an AnimationEvent before React loads (src/test/before-react.ts): finding none, React listened for the animation events under their WebKit names, and a test's "animationiteration" reached no handler.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:38:44 +00:00
khannurien
62e51f1622 v4: the six styles are reworked, each around one idea of its own, and the tab row stops painting the page — a pass over every theme, then the faults it showed in what they share. Brutalist draws every box in ink with a solid offset shadow and sets the palette's colors as flat blocks (a teal tag, an apricot quote, an inked label); its dark scheme keeps that ink darker than the page, on Twilight Indigo, where eggshell lines glared across every frame. GeoCities goes to the desktop of its day: the header is the taskbar, the site's name its Start button, the controls grey bevels, and a marquee crawls under the header (Marquee.tsx, drawn in that style alone), with Comic Neue where the system has no Comic Sans. Melange leaves its browns and Fraunces for a sorbet blend in Bricolage Grotesque, run along the header, through the site's name, where it drifts, and between a journal's blocks; its two schemes are one print and its negative, trading ink and paper while the pink and the plum written on it stay, and they share one fine grain where the light one had a coarse tile that greyed the page. Kanagawa opens the comments and the related dumps on a status line and reads on Lotus's brightest paper; its page is flat. Times draws its heavy rules in a mid grey at night, where ink is near white, and sets a journal cell with no picture as text. In what the styles share: a row of tabs (TabBar) hid the tabs scrolled under its scope switcher, and faded its ends, by painting the page's color over them, which showed as a patch on any page that is a gradient or a picture. The switcher now sits before the row that scrolls, which clips the tabs at its edge, and the fades are a mask (--tab-fade-start and --tab-fade-end); useRoomBeside measures the row through that one more level. A page's title keeps a word whole unless it alone is wider than the line: Firefox sizes a title set with negative letter-spacing a hair narrower than it lays it out, which dropped its last letter to a second line in any style that tightened it, so the rule is the base's and the two styles' copies go. A card holding one paragraph, such as the instances a new account hasn't signed in to on /settings, is spaced by its padding and not by the paragraph's margins as well. The site's name answers the pointer as a card's title does in each style, where two of them left it still. The emails follow the styles' tokens, Melange's blend among them.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 55s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:30:57 +00:00
khannurien
eb4e64683c v4: the Times nameplate sits level with the header's buttons — the name was 7 pixels under their middle on a wide screen and 3 on a narrow one, with twice the paper above its letters as below. The blackletter sets its letters low in its line, an ascender stopping a fifth of an em under the line's top and a descender ending on its bottom, and the nameplate's padding was the heavier above besides. The name now carries 0.22em under its line, which leaves as much below the letters as above at either size, and the nameplate's padding is the same on both sides, 0.6rem and 0.7rem from 860 pixels up. The wide masthead's row is a pixel taller, the narrow header keeps its 60.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 53s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:30:02 +00:00
khannurien
d54ae554a4 v4: the Times style gets a masthead and a face for each job — one condensed serif did everything, buttons and tabs included, under a plain header bar. The site's name is now a blackletter nameplate (Manufacturing Consent), its emoji beside it on the same baseline at the size of a printer's mark. From 860 pixels up it takes the middle of a row of its own, the buttons to its right, and what a page puts in the header, a feed's tabs or the open search field, runs underneath between a hairline and a double rule; a page that puts nothing there keeps one row and a single ink rule. Instrument Serif stays for headlines, at its one weight and larger sizes, Newsreader sets what is read at length (summaries, notes, comments), and Libre Franklin everything the interface says: controls, tabs, datelines, bylines, labels (--font-ui, listed in themes/nyt.css). Tabs are sentence case with an ink underline on the open one. The comments, the related dumps, a profile's sections and a page's groups open on an ink rule with their label in bold capitals under it. A dump's categories are small capital kickers, where they were grey chips. Buttons and fields take a 3px radius, pictures, cards and sheets staying square. The header sits on the page's color in the dark scheme too. In the journal each cell draws its four rules as a 1px shadow in a 1px gap, where it drew its top and left edges: a last row the dumps don't fill was left open on its right, and the row above it unruled over the empty tracks. A page's title keeps a word whole unless it is wider than the line. deno.lock also takes the key format of the Deno that wrote it, with no version changed beyond the three font packages.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 53s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:26:09 +00:00
khannurien
3e15e9a4f2 v4: /follow/playlists opens the followed playlists' feed, and a page's test opens it through the app's routes — the path was a 404 since the two views of the followed feed became pages: the route table held each feed tab as an exact path, /follow, so /follow/playlists fell to /:handle/*, which read "follow" as a category and "playlists" as a tab it hasn't. /music/follow/playlists, which goes through that route, worked. The tabs' routes now take what follows them (/${tab}/*), which Feed already reads and refuses when it names no view.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 57s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 28s
No test saw it because each page's test mounted the page on a route pattern of its own, a copy of App's, with stand-in pages where a redirect lands: Index's parsed the path itself, and passed on one the app doesn't serve. renderPage (src/test/render.tsx) now opens a URL through AppRoutes, which App.tsx exports for it, inside the real providers, and every page's test uses it: the page is the one the app routes that URL to, behind its guard, and a redirect is checked by the path it lands on. An entry may be a location, to arrive with the state a link hands over. Every page being loaded with its route, the first query after opening one waits. src/test/App.test.tsx tests the table itself: the paths that are a feed, those that are not found, those that send a guest to sign in and back, and those that send a signed-in user home.

The profile's test held /@ann/dumps/x to be a path the profile answers as not found, which only its own pattern made true: in the app it is a dump's page. It now asks for /@ann/dumps/x/y. The tests of the two guards still mount them around a stand-in child, being tests of the guard alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:59:52 +00:00
khannurien
cbb770bff8 v4: a still is scaled from its first frame alone, a second image in its file made the copy flash — an avatar came out as a WebP of two frames looping at 40 ms each, the picture then a blank. A still's file may hold more than one image, a phone's JPEG carrying a gain map or a depth map after the photo, and ffmpeg reads each as a frame: only the JPEG and PNG copies were held to one, the WebP keeping them all as it does a GIF's. scalableImage (lib/upload.ts) now says whether the image is an animation, which a GIF alone is, and scaleImage (lib/ffmpeg.ts) writes one frame of anything else, in every format; the image proxy scales through the same call. The copies already made stay, a copy being current while it has its upload's date: uploads/scaled is to be deleted once on an instance that ran the previous version, everything in it being made again on first request.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 52s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:40:14 +00:00
khannurien
d2b7d21824 v4: the page a visit lands on asks for its content while its code loads — a first load went one step after the other: the entry script, then the interface's messages, then the page's chunk and its twenty-odd dependencies, and only then the request for the feed. The built page now carries a script in its head that asks for the messages' chunk at once, beside the entry, picking the language as detectLocale does (preloadCatalog in vite.config.ts; the two must agree). And main.tsx sends the landing page's first request as the app boots (utils/shared/earlyFetch.ts): the feed's first page on / and its tabs, the dump on a dump's page, as the stored session. authFetch hands that response to the page that asks for the same URL, once, to the session it was sent as and within ten seconds, in place of a request of its own; past that the page asks again. feedApiUrl (utils/shared/urls.ts) is the feed's URL for both. A category's feed, a profile, a playlist and the other pages wait for their code as before. On a local build with 150 ms of latency added, the feed's request leaves at 0.69 s where it left at 1.70 s, and the first card shows at 1.59 s where it showed at 1.92 s.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:36 +00:00
khannurien
90fc281e90 v4: every image the API serves comes scaled to the size it is shown at — a first visit to the feed loaded 4.8 MB of images out of 5.1 MB in all: an avatar shown at 32 pixels was the upload itself, a 1.4 MB GIF among them, and so were a cover, a custom thumbnail, an attachment and a proxied image, only an image dump having a preview, 960 pixels wide. Every route that answers an image now takes ?w= and sends a WebP copy at most that wide, of the widths shared/image.ts lists, 128, 320, 640 and 960, a width between two taking the one above, and never wider than the image; without it the upload goes out as before, a dump's thumbnail alone being scaled to the widest by default, the custom ones included. serveImage (lib/upload.ts) is where they all end, and the image proxy scales what it fetched the same way, keeping each width in its cache. lib/ffmpeg.ts makes the copies as it made the previews, which they replace: on first request, under uploads/scaled/ at the upload's own path, each given its upload's date and made again when the two differ, so a new avatar or cover needs nobody to delete the old ones. WebP keeps a PNG's transparency and a GIF's animation. A JPEG is turned by its EXIF orientation here, ffmpeg's own rotation of images depending on its version. An animated WebP, which ffmpeg can't read, and an image it fails on go out as they are, a failure being remembered until the upload changes. ?f=compat asks for the copy as a JPEG, or a PNG for what may be transparent: the digest's emails take their thumbnails that way, at 640 pixels, not every mail client reading WebP. On the client, imageProps (utils/shared/urls.ts) gives an image its srcset of those copies and the width it is laid out at, which config/imageSizes.ts states for each place from App.css: Avatar, Thumbnail, which scales an API image and leaves another site's as that site sends it, and the playlists' covers use it. The images of notes and comments still load as uploaded. The copies go with the dump, the custom thumbnail or the attachment they were made from, and generatedMediaSweepJob, a minute after the start then daily, takes those whose upload is gone, the previews under their former name and the temporary files a generation left unfinished.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:36 +00:00
khannurien
6ab398e687 v4: the API's JSON and the page are sent compressed — a page of the feed went out as it was, twenty kilobytes where a fifth does: only the built frontend's files were compressed, at build time, and Deno's server leaves alone a response whose length Oak has set. compressMiddleware (api/middleware/compress.ts), installed after the error middleware and around everything that sets a body, sends an object or array, and a string whose type its route set, in brotli or gzip as the request's Accept-Encoding takes, a coding refused with q=0 not being taken, from a kilobyte up, the threshold the build compresses from. Brotli is at quality 5, the frontend's files, compressed once, staying at 11. Files, streams and bytes, a response already encoded, a HEAD and one marked no-transform go out as they are, and a compressible answer says Vary: Accept-Encoding whether compressed or not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:06 +00:00
khannurien
1bd5c924b0 v4: Dark Reader is kept off the pages, which lost every background under it — on a phone in dark mode with the extension on, the avatar menu, the cards and the tabs were transparent over Dark Reader's own page colour, in every style. The build gives each light-dark() a fallback for the browsers without it, two custom properties of which one is empty, and Dark Reader's rewrite of the stylesheet drops the empty one under a dark system theme: both halves of every colour then apply at once, which is no colour, so nothing paints a background or a border. index.html now carries <meta name="darkreader-lock">, which the extension obeys by generating nothing; the site has dark palettes of its own, following the system or the user's choice. The dev server never showed it, serving light-dark() as written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:26:21 +00:00
khannurien
de223baaa4 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.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:14:55 +00:00
khannurien
561cdf98b0 v4: playlists sort by their last dump, their creation or their size, and /playlists is a grid of covers — every list of playlists came newest first, so one fed every week sank under those made since and left empty. GET /api/playlists and /api/users/:key/playlists take ?sort=updated|new|dumps (PLAYLIST_SORTS, shared/playlist.ts), updated being the default: the last to take a dump first, or its creation while it holds none; a new title, cover or description doesn't bring it back up. A playlist carries that date as lastDumpAt, read with its dumpCount by isMemberVisibleTo's rule (VISIBLE_DUMP_FIGURES, the viewer's id as ?1), so the owner's private dumps date and count their playlists for them alone, in the lists and over the socket: playlist_created, playlist_updated and playlist_dumps_updated carry each socket's own. The /playlists page and a profile's playlists tab show the orders as tabs beside the category menu and keep the one picked in ?sort=, the default being the bare path; a tab takes the current history entry's place, and picking a category keeps the order. Live, usePlaylistListSync takes the list's order: a new playlist goes where it puts it, one that rises moves up to its place, as in the updated order when it takes a dump, and one that falls stays until the list is fetched again, its place being perhaps on a page not loaded. The lists in an order of their own, the followed playlists and the search's, pass none, and take no new playlist as before (noNewEntries goes). comparePlaylists is the order on the client, which a test holds to the API's.
/playlists lays its cards out as tiles, the cover across the top, four to a row and two on a phone, on the wide column: the same PlaylistCard, laid out by its list's class (.playlist-grid), so each theme's card skin carries over. It drops the "public" badge there, every playlist listed being public. On every card, a playlist without a cover shows a tile of its own colour with its initial (ThumbnailPlaceholder) in place of the 📋, and the date is "updated …" of its last dump, or "created …".

A middle, ctrl, cmd or shift click anywhere on a playlist card opens it in a new tab — only its title was a link, the rest navigating by a click handler. useCardLink (hooks/shared) is useDumpCardLink's handlers for any card, which the dump cards now get through it.

A profile tab's query stays with the tab — going from the playlists to the dumps kept the whole query, so a playlist category, and now ?sort=, landed on the dumps' URL. Only the dump lists pass their category to each other; any other change of tab drops the query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:51:37 +00:00
khannurien
350e945eb1 v4: a new link between two dumps shows among their related dumps at once — it showed on the next load of the page: the backlink service recorded the link without telling anyone, and the dump page fetched its related dumps once, the socket then only editing or removing the ones it had. relinkBacklinks now sends dump_related_changed when a note or a comment gains or loses a link, naming the dump and those at the other end, and the page showing one of them fetches /related again. A link shows to those who may see both its ends, so each socket is told only of the dumps its user may see, and not at all without one at each end. A private dump that turns public sends it too, for the links it already had, which are new to everyone but its poster. The page also fetches its related dumps again on a reconnection, as it does its comments and playlists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:43:55 +00:00
khannurien
dde0232acb v4: a link from one dump's page to another's opens that dump — it reloaded the one on screen: the page stays mounted when its path changes, and for one render the old dump was still loaded under the new path, so useCanonicalPath took that path for a former one and put the old dump's back, which was then fetched again. A related dump's link did it, and so did any dump link in a note or a comment. The page now drops the dump on screen in the render that sees a path naming another, as usePlaylist and useProfile do. The dump handed over by DumpEdit or a title edit goes with it, so that coming back to its path fetches it instead of matching that snapshot and loading forever.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:34:32 +00:00
khannurien
7dd00068df v4: the header has two playlists links, each to its own page — it had three: the text button and the avatar menu's "Playlists" both led to the user's own playlists, and the menu's "All playlists" to /playlists. The text button now leads to /playlists, every public playlist, and shows to guests too, the page being open to them. The menu keeps one entry, "My playlists", to the user's own. Below 1150px, where the text button is hidden, the header no longer links to /playlists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:30:45 +00:00
khannurien
28f910b00e v4: a playlist's dump count is of the dumps the viewer may see — it counted every dump in the playlist, so a card said "3 dumps" over a page listing two, and told everyone how many private dumps a playlist held. The lists (/playlists, a profile's playlists and followed ones, the search, a dump's playlists, the add-to-playlist panel) now count by isMemberVisibleTo's rule, in SQL as VISIBLE_DUMP_COUNT (lib/playlist-members.ts): the public dumps, and for the owner their own private ones. GET /api/playlists and /api/users/:key/follow/playlists read the optional sign-in for it. The community page and the digests count the public dumps, their figures being the same for every reader. Live, playlist_created and playlist_updated carry each socket's own count: broadcastChange builds the owner's message and the others' apart. playlist_dumps_updated loses its dumpCount, a client counting the dumps it is sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:25:24 +00:00
khannurien
ffc071af4c v4: a description shows as text where a list quotes it — a playlist's card printed its description as written, so one holding an image showed "![](/api/attachments/…)", and so did a user's line in the search results. Both now go through markdownToPlainText (shared/markdown.ts), as the chat's reply previews do: the markup goes, a link keeps its text, an image is left out, and a description of nothing but an image leaves no line. The playlist's page and the profile render the Markdown as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:12:44 +00:00
khannurien
1d56c323a4 v4: the community page's figures stay on one line when a label wraps — each figure is a column-reverse flex box stretched to the row's height, and the default justify-content packed it at the bottom, so a label on two lines ("messages dans la tribune") lifted its number above the others. justify-content: flex-end puts each figure at the top of its cell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:10:27 +00:00
157 changed files with 8329 additions and 2486 deletions

View File

@@ -1,11 +1,20 @@
FROM denoland/deno:bin-2.9.0 AS deno
FROM mcr.microsoft.com/devcontainers/typescript-node:20
FROM denoland/deno:bin-2.9.7 AS deno
FROM mcr.microsoft.com/devcontainers/typescript-node:26
COPY --from=deno /deno /usr/local/bin/deno
# The API draws thumbnails and waveforms with ffmpeg, in the workspace and in
# the instances' services alike (compose.yaml), which don't get the features.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& 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
# 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

35
API.md
View File

@@ -15,9 +15,11 @@ default view is the bare path, with no redirect. A category is a segment where
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
narrows a list on another page, a profile's or a search's, it is `?category=`,
of the kind that list holds. Switching tabs, or a tab's views, takes the place
of the current history entry; picking a category, or opening another page or an
editor, adds one.
of the kind that list holds. The order of a list of playlists is `?sort=`, the
default order being the bare path, and the playlists page's list of the viewer's
own is `?mine=1`. Switching tabs, a tab's views or a list's order takes the
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
a user's, which may also be `@name`. The `@` keeps the two apart, so no regex
@@ -33,14 +35,14 @@ has one word, the same as in pages.
| `/` | `/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists`; the bare `/` is the viewer's default tab, the bare `/follow` the users followed | anyone, `/follow` signed in | `Index` |
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` |
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists | anyone | `UserPublicProfile` |
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists; `?sort=` orders the playlists | anyone | `UserPublicProfile` |
| `/@<name>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
| `/playlists` | `/playlists/<category>`, a playlist category's | 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` |
| `/notifications` | | signed in | `Notifications` |
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
@@ -103,6 +105,15 @@ status. A list answers `{ "items", "hasMore" }` and takes `?page=` (from 1) and
thumbnail, waveform, cover or attachment takes the owner's media token as
`?token=`.
An image the API serves, an avatar, a thumbnail, a cover, an attachment or a
remote image, takes `?w=` and answers a WebP copy scaled down to that width,
snapped up to one of `shared/image.ts`'s (128, 320, 640, 960), and never wider
than the image; without it, the image as it was uploaded, a dump's thumbnail
alone being scaled to the widest by default. `?f=compat` asks for the copy as a
JPEG or PNG, for a mail client that reads no WebP. An image no copy can be made
of goes out as it is. JSON answers of a kilobyte or more are compressed, in
brotli or gzip, for a client that accepts it.
### me
- `GET /api/me` — signed in. The caller's own user.
@@ -135,7 +146,7 @@ there: those endpoints answer it 400.
- `GET /api/users/:key/avatar` — anyone. The avatar.
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items
to their owner only. Their lists; `?category=` narrows one to a category of
its kind.
its kind, and `?sort=` orders the playlists as `GET /api/playlists` does.
- `GET /api/users/:key/dumps/:slug`, `/playlists/:slug` — anyone; private items
to their owner only. An item by its page path, by an alias too.
- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they
@@ -174,8 +185,10 @@ there: those endpoints answer it 400.
- `POST /api/playlists` — signed in. Creates a playlist, filed under the
playlist categories it names.
- `GET /api/playlists` — anyone. Every public playlist, newest first;
`?category=<slug>` narrows it to a playlist category.
- `GET /api/playlists` — anyone. Every public playlist; `?category=<slug>`
narrows it to a playlist category, and `?sort=updated|new|dumps` orders it:
the last to take a dump first (the default), the newest, or the fullest. The
caller's own private dumps count, and date their playlists, for them alone.
- `GET /api/playlists/:id` — anyone who may see it. A playlist and its dumps.
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title,
slug, description, visibility, categories; deletion.
@@ -203,6 +216,12 @@ there: those endpoints answer it 400.
- `GET /api/community?period=weekly|monthly` — signed in. The site's top dumps,
new members, new public playlists and totals over the last week (by default)
or month, as of the server's last refresh.
- `GET /api/community/feed` — signed in. The community feed: the 12 latest
things done in public, newest first (a public dump posted, a public playlist
created, a comment or an upvote on a public dump, someone joining), and the
last week's totals and most upvoted dump, as of the server's last refresh. A
dump has one comment event and one upvote event at most, its latest. Every
reader gets the same feed.
### chat

View File

@@ -199,7 +199,7 @@ api/
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
sql/
gerbeur.db # SQLite database (not committed); <name>.db per dev instance
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed); <name>/ per dev instance
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … and scaled/, their copies made on request (not committed); <name>/ per dev instance
src/ # React frontend (Vite)
config/ # API base URL, default feed tab, player mode, instance settings
pages/ # One component per route, nothing else

View File

@@ -120,6 +120,12 @@ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
* thumbnails route generates from file dumps.
*/
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
/**
* Holds the scaled-down copies of the images above, which lib/ffmpeg.ts makes
* as they are first asked for, each at its upload's path with its width. It
* can be emptied at any time: the copies are made again.
*/
export const SCALED_DIR = `${UPLOADS_DIR}/scaled`;
/**
* Caps the size of an image upload, in bytes, for avatars, covers, thumbnails

View File

@@ -11,6 +11,7 @@ import {
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
UNUSED_INVITES_RETENTION_DAYS,
} from "../config.ts";
import { removeScaledImages } from "../lib/ffmpeg.ts";
import { hashPassword } from "../lib/password.ts";
/**
@@ -60,6 +61,7 @@ export async function runStartupTasks(db: DatabaseSync): Promise<void> {
if (orphanedAttachments.length > 0) {
for (const { id } of orphanedAttachments) {
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
await removeScaledImages(`${ATTACHMENTS_DIR}/${id}`);
}
db.prepare(`DELETE FROM attachments WHERE ${ORPHANED};`)
.run(attachmentCutoff);

View File

@@ -1,15 +1,26 @@
/**
* Makes what the server derives from a file dump's upload with ffmpeg: a
* video's still and an image's scaled-down copy, which the thumbnails route
* serves, and an audio file's waveform, which the waveform route serves. Each
* is made on its first request and kept in `THUMBNAILS_DIR` under a name this
* module gives, until dump-service replaces or deletes the upload and
* {@link removeGeneratedMedia} drops them.
* Makes what the server derives from an upload with ffmpeg: a video's still,
* which the thumbnails route serves, an audio file's waveform, which the
* waveform route serves, and the scaled-down copies of any image, which
* `serveImage` (lib/upload.ts) serves for every route that answers one. Each
* is made on its first request and kept under a name this module gives, a
* dump's in `THUMBNAILS_DIR` and the scaled copies in `SCALED_DIR`, until
* dump-service replaces or deletes the upload and
* {@link removeGeneratedMedia} drops them. {@link generatedMediaSweepJob}
* takes what that leaves behind.
*
* @module
*/
import { THUMBNAILS_DIR } from "../config.ts";
import { dirname, relative } from "node:path";
import {
DUMPS_DIR,
SCALED_DIR,
THUMBNAILS_DIR,
UPLOADS_DIR,
} from "../config.ts";
import { IMAGE_WIDTHS, type ImageWidth } from "../../shared/image.ts";
import type { Job } from "./scheduler.ts";
import { type DumpWaveform, peaksOf } from "../../shared/waveform.ts";
import type { APIResponse } from "../../shared/api.ts";
@@ -48,7 +59,7 @@ const VIDEO_FORMATS =
* Lists the formats ffmpeg may read an image as, for the reason given at
* {@link VIDEO_FORMATS}.
*/
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe,gif";
/**
* Lists the formats ffmpeg may read an audio file as, for the reason given at
* {@link VIDEO_FORMATS}; an `.m4a` or a `.webm` is read by its container's.
@@ -57,35 +68,50 @@ const AUDIO_FORMATS =
"mp3,ogg,flac,wav,aiff,aac,asf,caf,w64,wv,ape,mov,mp4,m4a,matroska,webm";
/**
* Maps the image types that get a scaled-down preview to the preview's format.
* A GIF is served as it is, to keep its animation.
* Returns where the still and the waveform generated from dump `dumpId`'s
* upload are kept.
*/
export const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
"image/jpeg": "jpg",
"image/bmp": "jpg",
// May carry transparency, which JPEG would fill with black.
"image/png": "png",
"image/webp": "png",
};
/**
* Caps an image preview's width. It covers the widest feed card on a
* high-density screen, at a fraction of a phone photo's bytes.
*/
const PREVIEW_WIDTH = 960;
/** Returns where the media generated from dump `dumpId`'s upload are kept. */
export function generatedPaths(dumpId: string): {
still: string;
preview: Record<"jpg" | "png", string>;
waveform: string;
} {
const base = `${THUMBNAILS_DIR}/${dumpId}`;
return {
still: `${base}.jpg`,
preview: { jpg: `${base}.preview.jpg`, png: `${base}.preview.png` },
waveform: `${base}.waveform.json`,
};
return { still: `${base}.jpg`, waveform: `${base}.waveform.json` };
}
/**
* The formats a scaled copy comes in: WebP for a browser, and a JPEG or a PNG
* for what doesn't read it, as some mail clients don't.
*/
export type ScaledFormat = "webp" | "jpg" | "png";
const SCALED_FORMATS: readonly ScaledFormat[] = ["webp", "jpg", "png"];
/**
* Returns where the copy of the upload at `srcPath` scaled to `width` is
* kept, in `format`: under `SCALED_DIR`, at the upload's own path below
* `UPLOADS_DIR`.
*/
export function scaledImagePath(
srcPath: string,
width: ImageWidth,
format: ScaledFormat = "webp",
): string {
return `${SCALED_DIR}/${relative(UPLOADS_DIR, srcPath)}.w${width}.${format}`;
}
/**
* Deletes the scaled copies of the upload at `srcPath`, once it is deleted.
* One that is replaced needs no call: its copies are made again, being older
* than it ({@link ensureGenerated}).
*/
export async function removeScaledImages(srcPath: string): Promise<void> {
await Promise.all(
IMAGE_WIDTHS.flatMap((width) =>
SCALED_FORMATS.map((format) =>
Deno.remove(scaledImagePath(srcPath, width, format)).catch(() => {})
)
),
);
}
/**
@@ -93,12 +119,11 @@ export function generatedPaths(dumpId: string): {
* has replaced or deleted it. The routes make them again from a new one.
*/
export async function removeGeneratedMedia(dumpId: string): Promise<void> {
const { still, preview, waveform } = generatedPaths(dumpId);
await Promise.all(
[still, preview.jpg, preview.png, waveform].map((path) =>
Deno.remove(path).catch(() => {})
),
);
const { still, waveform } = generatedPaths(dumpId);
await Promise.all([
...[still, waveform].map((path) => Deno.remove(path).catch(() => {})),
removeScaledImages(`${DUMPS_DIR}/${dumpId}`),
]);
}
/**
@@ -109,7 +134,7 @@ async function writeAtomically(
outPath: string,
write: (tmpPath: string) => Promise<void>,
): Promise<void> {
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
await Deno.mkdir(dirname(outPath), { recursive: true });
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
try {
await write(tmpPath);
@@ -156,25 +181,104 @@ export function videoStill(srcPath: string, outPath: string): Promise<void> {
], outPath);
}
/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */
export function imagePreview(srcPath: string, outPath: string): Promise<void> {
/**
* Maps a JPEG's EXIF orientation to the filters that turn its pixels upright.
* A browser applies the orientation to the upload itself; the copy carries no
* EXIF, so it is turned here, and not by ffmpeg's own autorotation, which
* only its recent versions apply to images.
*/
const ORIENTATION_FILTERS: Record<number, string> = {
2: "hflip",
3: "hflip,vflip",
4: "vflip",
5: "transpose=0",
6: "transpose=1",
7: "transpose=3",
8: "transpose=2",
};
/** What ffmpeg is told to write each {@link ScaledFormat} with. */
const ENCODERS: Record<ScaledFormat, string[]> = {
webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"],
jpg: ["-c:v", "mjpeg", "-q:v", "4", "-f", "image2"],
png: ["-c:v", "png", "-f", "image2"],
};
/**
* What {@link scaleImage} is told of the image it scales, which
* `scalableImage` (lib/upload.ts) reads from its first bytes.
*/
export interface ImageTraits {
/** The EXIF orientation to turn the image by, 1 when it is upright. */
orientation: number;
/**
* Whether its frames are an animation, as a GIF's are. A still may hold
* more than one image too, a phone's JPEG carrying a gain map or a depth
* map after the photo, and ffmpeg reads each as a frame.
*/
animated: boolean;
}
/**
* Writes a copy of an image at most `width` wide, never wider than the image
* itself, as a WebP unless `format` names another. WebP keeps a PNG's
* transparency and a GIF's animation, at a fraction of either's bytes.
*/
export function scaleImage(
srcPath: string,
outPath: string,
width: ImageWidth,
{ orientation, animated }: ImageTraits,
format: ScaledFormat = "webp",
): Promise<void> {
const upright = ORIENTATION_FILTERS[orientation];
// A JPEG's colour planes halve its height, which they need even.
const height = format === "jpg" ? -2 : -1;
// Only an animation keeps its frames, and only as a WebP: of anything else
// the first is the image, and a copy of them all would flash through what
// follows it.
const frames = animated && format === "webp" ? [] : ["-frames:v", "1"];
return runFfmpeg([
"-noautorotate",
"-format_whitelist",
IMAGE_FORMATS,
"-i",
srcPath,
"-frames:v",
"1",
"-vf",
`scale='min(${PREVIEW_WIDTH},iw)':-2`,
"-f",
"image2",
...(outPath.endsWith(".png")
? ["-c:v", "png"]
: ["-c:v", "mjpeg", "-q:v", "4"]),
`${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`,
"-an",
...frames,
...ENCODERS[format],
], outPath);
}
/**
* Returns `bytes`, an image held in memory, scaled as {@link scaleImage}
* does, or null when it can't be, because ffmpeg is missing or failed. The
* image proxy scales what it fetched with it, and keeps the result itself.
*/
export async function scaleImageBytes(
bytes: Uint8Array,
width: ImageWidth,
traits: ImageTraits,
): Promise<Uint8Array | null> {
if (!await ffmpegAvailable()) return null;
const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`;
const outPath = `${srcPath}.webp`;
try {
await Deno.mkdir(dirname(srcPath), { recursive: true });
await Deno.writeFile(srcPath, bytes);
await scaleImage(srcPath, outPath, width, traits);
return await Deno.readFile(outPath);
} catch {
return null;
} finally {
await Promise.all(
[srcPath, outPath].map((path) => Deno.remove(path).catch(() => {})),
);
}
}
/**
* The sample rate the audio is decoded at for its waveform. A bar stands for
* a second or more of a song, so 8 kHz keeps every peak worth drawing at a
@@ -269,20 +373,140 @@ export function audioWaveform(srcPath: string, outPath: string): Promise<void> {
*/
const inFlight = new Map<string, Promise<void>>();
/**
* Spares an ffmpeg run per request for an image it can't read: the
* generations that failed, by output path, with the date of the source they
* failed on. A new upload gets a new try.
*/
const failed = new Map<string, number>();
/** Returns the date `path` was last written, or null when it doesn't exist. */
async function writtenAt(path: string): Promise<number | null> {
try {
return (await Deno.stat(path)).mtime?.getTime() ?? 0;
} catch {
return null;
}
}
/**
* Makes `outPath` with `generate` unless it exists. Resolves false when it
* can't be made, because ffmpeg is missing or failed.
*
* With `srcPath`, the file it is made from, `outPath` takes that file's date,
* and is made again when the two differ: an upload replaced in place, as an
* avatar or a cover is, gets new copies without anyone deleting the old.
* Without it, the caller deletes `outPath` when its source changes.
*/
export async function ensureGenerated(
outPath: string,
generate: () => Promise<void>,
srcPath?: string,
): Promise<boolean> {
if (await Deno.stat(outPath).then(() => true, () => false)) return true;
const made = await writtenAt(outPath);
let version: number | null = null;
if (srcPath) {
version = await writtenAt(srcPath);
if (version === null) return false;
if (made === version) return true;
if (failed.get(outPath) === version) return false;
} else if (made !== null) {
return true;
}
if (!await ffmpegAvailable()) return false;
let pending = inFlight.get(outPath);
if (!pending) {
pending = generate().finally(() => inFlight.delete(outPath));
pending = generate()
.then(() =>
version === null
? undefined
: Deno.utime(outPath, new Date(), new Date(version))
)
.catch((err) => {
if (version !== null) failed.set(outPath, version);
throw err;
})
.finally(() => inFlight.delete(outPath));
inFlight.set(outPath, pending);
}
return await pending.then(() => true, () => false);
}
/**
* How old a temporary file must be for the sweep to take it: one being
* written, by a generation under way, is seconds old, and one a crash left
* is never finished.
*/
const ABANDONED_AFTER_MS = 60 * 60_000;
/** Yields the path of every file under `dir`, and nothing when it is missing. */
async function* filesUnder(dir: string): AsyncGenerator<string> {
let entries: Deno.DirEntry[];
try {
entries = await Array.fromAsync(Deno.readDir(dir));
} catch {
return;
}
for (const entry of entries) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory) yield* filesUnder(path);
else if (entry.isFile) yield path;
}
}
/**
* Deletes the generated files nothing serves any more, and returns how many:
* the scaled copies of an upload that is gone, which the services' own
* removals missed or never made, as for a user's avatar; the previews of
* image dumps under their former name, `<id>.preview.jpg` or `.png`, which
* the scaled copies replaced; and the temporary files a generation left
* unfinished. Nothing else of `THUMBNAILS_DIR` is touched, the custom
* thumbnails kept there being uploads.
*/
export async function sweepGeneratedMedia(): Promise<number> {
let removed = 0;
const remove = (path: string) =>
Deno.remove(path).then(() => {
removed++;
}, () => {});
const abandoned = async (path: string) => {
const at = await writtenAt(path);
return at !== null && Date.now() - at > ABANDONED_AFTER_MS;
};
for await (const path of filesUnder(THUMBNAILS_DIR)) {
if (
/\.preview\.(jpg|png)$/.test(path) ||
(path.endsWith(".tmp") && await abandoned(path))
) await remove(path);
}
for await (const path of filesUnder(SCALED_DIR)) {
// A copy is named after its upload's path: see scaledImagePath.
const copy = relative(SCALED_DIR, path).match(
/^(.+)\.w\d+\.(?:webp|jpg|png)$/,
);
const unused = copy
? await writtenAt(`${UPLOADS_DIR}/${copy[1]}`) === null
: await abandoned(path);
if (unused) await remove(path);
}
return removed;
}
/**
* Runs {@link sweepGeneratedMedia} a minute after the server starts, which
* clears what a new version no longer uses as soon as it is deployed, then
* once a day. `main.ts` starts it.
*/
export const generatedMediaSweepJob: Job = {
name: "media-sweep",
everyMs: 24 * 60 * 60_000,
firstAfterMs: 60_000,
async run() {
const removed = await sweepGeneratedMedia();
if (removed > 0) {
console.log(`[media-sweep] removed ${removed} generated files unused`);
}
},
};

View File

@@ -1,7 +1,8 @@
/**
* Decides which of a playlist's dumps a viewer may see. The playlist service
* filters its listings with it, and the WebSocket service filters each
* client's `playlist_dumps_updated` message the same way.
* filters its listings with it, the WebSocket service filters each client's
* `playlist_dumps_updated` message the same way, and the queries that list
* playlists count their dumps by the same rule.
*
* @module
*/
@@ -34,3 +35,53 @@ export function isMemberVisibleTo(
return !member.isPrivate ||
(member.userId === viewerId && viewerId === playlist.userId);
}
/**
* The fields of a playlist's dump that {@link dumpFiguresFor} needs: those
* that tell who may see it, and when it was added. The playlist service reads
* them for its broadcasts.
*/
export interface DatedPlaylistMember extends PlaylistMember {
addedAt: Date;
}
/** A figure of the dumps of `playlists p` that the viewer `?1` may see. */
const visibleDumps = (figure: string) =>
`(SELECT ${figure} FROM playlist_dumps pd
INNER JOIN dumps d ON d.id = pd.dump_id
WHERE pd.playlist_id = p.id
AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?1))
)`;
/**
* The `dump_count` and `last_dump_at` columns of a query on `playlists p`:
* how many of the playlist's dumps the viewer may see, by
* {@link isMemberVisibleTo}'s rule, and when the latest of them was added,
* null without any. Both read the viewer's id, or `""` for a signed-out one,
* as `?1`: it is the query's first parameter, and each `?` after it takes the
* next.
*/
export const VISIBLE_DUMP_FIGURES = `${visibleDumps("COUNT(*)")} as dump_count,
${visibleDumps("MAX(pd.added_at)")} as last_dump_at`;
/**
* Returns what `playlist` says of its dumps to the viewer, `members` being
* all of them: the `dumpCount` and `lastDumpAt` that
* {@link VISIBLE_DUMP_FIGURES} reads in a query. The WebSocket service puts
* them on the playlist each socket is sent.
*/
export function dumpFiguresFor(
members: DatedPlaylistMember[],
playlist: Pick<Playlist, "userId">,
viewerId: string | null | undefined,
): Required<Pick<Playlist, "dumpCount">> & Pick<Playlist, "lastDumpAt"> {
const visible = members.filter((m) =>
isMemberVisibleTo(m, playlist, viewerId)
);
return {
dumpCount: visible.length,
lastDumpAt: visible.length
? new Date(Math.max(...visible.map((m) => m.addedAt.getTime())))
: undefined,
};
}

View File

@@ -1,6 +1,7 @@
/**
* Checks the images users upload and serves them back. The avatar, playlist
* cover, dump thumbnail and attachment routes call it.
* Checks the images users upload and serves them back, scaled down to the
* width a request names. The avatar, playlist cover, dump thumbnail and
* attachment routes call it.
*
* @module
*/
@@ -10,6 +11,14 @@ import { APIException } from "./api-exception.ts";
import { serveFile } from "./serve-file.ts";
import { dirname } from "node:path";
import { MAX_IMAGE_SIZE_BYTES } from "../config.ts";
import { type ImageWidth, parseImageWidth } from "../../shared/image.ts";
import {
ensureGenerated,
type ImageTraits,
type ScaledFormat,
scaledImagePath,
scaleImage,
} from "./ffmpeg.ts";
/**
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic
@@ -62,10 +71,176 @@ export function validateImageUpload(data: Uint8Array): string {
}
/**
* Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds,
* and only by the browser when `isPrivate`. It answers 404 and resolves false
* when the file is missing. The caller checks first that the viewer may see
* it.
* Returns the EXIF orientation of a JPEG, 1 to 8, from the start of its
* bytes, and 1, upright, when it states none. A phone stores a photo as its
* sensor saw it and says here how to turn it.
*/
function jpegOrientation(data: Uint8Array): number {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
// The segments follow the two-byte SOI, each a marker and its length.
let at = 2;
while (at + 4 <= data.length && data[at] === 0xFF) {
const marker = data[at + 1];
const length = view.getUint16(at + 2);
// APP1 holding "Exif\0\0", then a TIFF header and its first directory.
if (marker === 0xE1 && view.getUint32(at + 4) === 0x45786966) {
const tiff = at + 10;
if (tiff + 8 > data.length) return 1;
const little = view.getUint16(tiff) === 0x4949;
const ifd = tiff + view.getUint32(tiff + 4, little);
if (ifd + 2 > data.length) return 1;
const entries = view.getUint16(ifd, little);
for (let i = 0; i < entries; i++) {
const entry = ifd + 2 + i * 12;
if (entry + 12 > data.length) return 1;
if (view.getUint16(entry, little) === 0x0112) {
const value = view.getUint16(entry + 8, little);
return value >= 1 && value <= 8 ? value : 1;
}
}
return 1;
}
// The image data starts: no EXIF came before it.
if (marker === 0xDA) return 1;
at += 2 + length;
}
return 1;
}
/**
* How much of a file {@link scalableImage} needs: the EXIF segment, which
* comes first in a JPEG, is 64 KB at most.
*/
const IMAGE_HEAD_BYTES = 64 * 1024 + 32;
/** Returned by {@link scalableImage}, for `scaleImage`. */
export interface ScalableImage extends ImageTraits {
/**
* The format of its copy for a reader without WebP: a JPEG, or a PNG for
* an image that may be transparent, which a JPEG would fill with black.
*/
compat: "jpg" | "png";
}
/**
* Returns how to scale the image whose first {@link IMAGE_HEAD_BYTES} are
* `head`, or null when it is to be served as it is: an animated WebP, which
* ffmpeg can't read, or anything but a JPEG, PNG, GIF, WebP or BMP.
*/
export function scalableImage(head: Uint8Array): ScalableImage | null {
const mime = detectImageMime(head);
// A file dump may be a BMP ("BM"), which no upload route here accepts.
if (!mime) {
return head[0] === 0x42 && head[1] === 0x4D
? { orientation: 1, animated: false, compat: "jpg" }
: null;
}
// "RIFF....WEBPVP8X", whose flags then say whether frames follow.
const animatedWebp = mime === "image/webp" && head[15] === 0x58 &&
(head[20] & 0x02) !== 0;
if (animatedWebp) return null;
return mime === "image/jpeg"
? { orientation: jpegOrientation(head), animated: false, compat: "jpg" }
: { orientation: 1, animated: mime === "image/gif", compat: "png" };
}
/** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */
async function readHead(path: string): Promise<Uint8Array | null> {
let file: Deno.FsFile;
try {
file = await Deno.open(path, { read: true });
} catch {
return null;
}
try {
const head = new Uint8Array(IMAGE_HEAD_BYTES);
let filled = 0;
while (filled < head.length) {
const n = await file.read(head.subarray(filled));
if (n === null) break;
filled += n;
}
return head.subarray(0, filled);
} finally {
file.close();
}
}
/** The type each format of a scaled copy is served as. */
const SCALED_MIMES: Record<ScaledFormat, string> = {
webp: "image/webp",
jpg: "image/jpeg",
png: "image/png",
};
/**
* Returns the copy of the image at `path` scaled to `width`, made now unless
* it was already, as its path and type, or null when there is none to serve:
* the image can't be scaled ({@link scalableImage}), or ffmpeg is missing or
* failed on it. The copy is a WebP, or with `compat` the JPEG or PNG a reader
* without WebP takes.
*/
async function scaledCopy(
path: string,
width: ImageWidth,
compat: boolean,
): Promise<{ path: string; mime: string } | null> {
// The other format depends on the image, so it is read before the copy is
// looked for; a WebP's path is known without.
let how = compat ? await describe(path) : undefined;
if (how === null) return null;
const format: ScaledFormat = how ? how.compat : "webp";
const scaledPath = scaledImagePath(path, width, format);
const made = await ensureGenerated(scaledPath, async () => {
how ??= await describe(path);
if (!how) throw new Error("Not an image that can be scaled");
await scaleImage(path, scaledPath, width, how, format);
}, path);
return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null;
}
/** Returns how to scale the image at `path`: see {@link scalableImage}. */
async function describe(path: string): Promise<ScalableImage | null> {
const head = await readHead(path);
return head && scalableImage(head);
}
/**
* Sends the image at `path`, of type `mime`, with `cacheControl`: a WebP copy
* at most as wide as the request's `?w=` names (`shared/image.ts`), or as
* `defaultWidth` without one, and the image as it is when the request names
* no width or the copy can't be made. With `?f=compat`, the copy is a JPEG,
* or a PNG for an image that may be transparent, which the digest's emails
* ask for, since not every mail client reads WebP. Every route that answers
* one of the site's own images ends here, so each takes both. It resolves
* false, having sent nothing, when the file is missing.
*/
export async function serveImage(
ctx: Context,
path: string,
mime: string,
cacheControl: string,
defaultWidth?: ImageWidth,
): Promise<boolean> {
// The browser must take the checked type, never guess another from the
// bytes.
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
const width = parseImageWidth(ctx.request.url.searchParams.get("w")) ??
defaultWidth;
const compat = ctx.request.url.searchParams.get("f") === "compat";
const scaled = width ? await scaledCopy(path, width, compat) : null;
if (
scaled &&
await serveFile(ctx, scaled.path, { mime: scaled.mime, cacheControl })
) return true;
return await serveFile(ctx, path, { mime, cacheControl });
}
/**
* Serves an uploaded image inline as `mime`, or scaled down as
* {@link serveImage} does, cached for `cacheMaxAge` seconds, and only by the
* browser when `isPrivate`. It answers 404 and resolves false when the file
* is missing. The caller checks first that the viewer may see it.
*/
export async function serveUploadedFile(
ctx: Context,
@@ -73,16 +248,18 @@ export async function serveUploadedFile(
mime: string,
cacheMaxAge = 3600,
isPrivate = false,
defaultWidth?: ImageWidth,
): Promise<boolean> {
ctx.response.headers.set("Content-Disposition", "inline");
// The browser must take the checked type, never guess another from the
// bytes, and a frontend on another origin may still show the image.
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
// A frontend on another origin may still show the image.
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
const served = await serveFile(ctx, filePath, {
const served = await serveImage(
ctx,
filePath,
mime,
cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
});
`${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
defaultWidth,
);
if (!served) ctx.response.status = 404;
return served;
}

View File

@@ -191,7 +191,7 @@ const RECIPES: Record<StyleName, Recipe> = {
},
},
melange: {
webFontAxes: "opsz,wght@9..144,400;9..144,600;9..144,700",
webFontAxes: "wght@400;600",
scale: 1,
avatarRadius: 16,
title: { size: 32, weight: 600 },

View File

@@ -3,7 +3,8 @@
* jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed
* the users out of other instances if they may no longer sign in there
* (`FEDERATION.outgoing`). A request goes
* through the error middleware, the OG tags and CORS, then the routers, one
* through the error middleware, the compression of what is answered, the OG
* tags and CORS, then the routers, one
* per resource of the API (`API.md` lists them), and last the built frontend,
* which answers every path no router did.
*
@@ -46,11 +47,13 @@ import {
PORT,
PUBLIC_URL,
} from "./config.ts";
import { compressMiddleware } from "./middleware/compress.ts";
import { errorMiddleware } from "./middleware/error.ts";
import { ogMiddleware } from "./middleware/og.ts";
import { routeStaticFilesFrom } from "./lib/static.ts";
import { startJobs } from "./lib/scheduler.ts";
import { rateLimitSweepJob } from "./lib/rate-limit.ts";
import { generatedMediaSweepJob } from "./lib/ffmpeg.ts";
import { digestJob } from "./services/digest-service.ts";
import { communityJob } from "./services/community-service.ts";
import { pingJob, typingExpiryJob } from "./services/ws-service.ts";
@@ -68,6 +71,7 @@ const app: Application = new Application();
const cors = oakCors({ origin: ALLOWED_ORIGINS });
app.use(errorMiddleware);
app.use(compressMiddleware);
app.use(ogMiddleware);
// oakCors calls next() without awaiting it when there is no origin, which
// corrupts the response in Oak's async chain. So it only sees requests from an
@@ -138,6 +142,7 @@ if (import.meta.main) {
pingJob,
typingExpiryJob,
rateLimitSweepJob,
generatedMediaSweepJob,
outboxJob,
]);
await app.listen({ hostname: LISTEN_HOST, port: PORT });

108
api/middleware/compress.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* Compresses what the routes answer in JSON, and the HTML page, for the
* browsers that accept it: a page of the feed is some twenty kilobytes as it
* is, and a fifth of that compressed. main.ts installs it after the error
* middleware, around everything that sets a body. The built frontend's files
* aren't its concern: lib/static.ts sends the copies compressed at build time.
*
* @module
*/
import type { Context, Next } from "@oak/oak";
import { promisify } from "node:util";
import { brotliCompress, constants as zlib, gzip } from "node:zlib";
const brotli = promisify(brotliCompress);
const gz = promisify(gzip);
/**
* Below this, the bytes saved aren't worth the work; it is the threshold the
* build compresses the frontend's files from (vite.config.ts).
*/
const MIN_BYTES = 1024;
/**
* Brotli's quality for a response made on the spot. Past 5 it takes several
* times longer for a few percent; the frontend's files, compressed once, are
* at 11.
*/
const BROTLI_QUALITY = 5;
/**
* Returns the coding to answer with for an `Accept-Encoding` header, brotli
* before gzip, or null when the client takes neither. A coding refused with
* `q=0` isn't taken.
*/
function pickCoding(header: string | null): "br" | "gzip" | null {
if (!header) return null;
const accepted = new Set<string>();
for (const part of header.split(",")) {
const [name, ...params] = part.trim().toLowerCase().split(";");
const q = params.map((p) => p.trim().match(/^q=([\d.]+)$/)?.[1])
.find((v) => v !== undefined);
if (q === undefined || Number(q) > 0) accepted.add(name.trim());
}
return accepted.has("br") ? "br" : accepted.has("gzip") ? "gzip" : null;
}
/**
* Returns the text of a body this middleware compresses, or null for any
* other: an object or array, which Oak would send as JSON, or a string whose
* type its route has set. Files, streams and bytes are left alone, as they
* are images, media and files compressed already.
*/
function textOf(ctx: Context): string | null {
const body = ctx.response.body;
if (typeof body === "string") {
// Oak guesses an untyped string's type from the string itself, which it
// couldn't from its compressed bytes.
return ctx.response.type || ctx.response.headers.has("Content-Type")
? body
: null;
}
if (body === null || typeof body !== "object") return null;
const proto = Object.getPrototypeOf(body);
if (proto !== Object.prototype && !Array.isArray(body)) return null;
ctx.response.type = "json";
return JSON.stringify(body);
}
/**
* Sends the response's JSON or text compressed, in brotli or gzip as the
* request accepts, when it is at least {@link MIN_BYTES} long. A response
* already encoded, a `HEAD`, and one marked `no-transform` go out as they are.
*/
export async function compressMiddleware(
ctx: Context,
next: Next,
): Promise<void> {
await next();
const headers = ctx.response.headers;
if (
ctx.request.method === "HEAD" || headers.has("Content-Encoding") ||
headers.get("Cache-Control")?.includes("no-transform")
) return;
const text = textOf(ctx);
if (text === null) return;
const raw = new TextEncoder().encode(text);
if (raw.length < MIN_BYTES) return;
// A cache must not hand the compressed copy to a client that can't read it.
headers.append("Vary", "Accept-Encoding");
const coding = pickCoding(ctx.request.headers.get("Accept-Encoding"));
if (!coding) return;
const compressed = coding === "br"
? await brotli(raw, {
params: {
[zlib.BROTLI_PARAM_MODE]: zlib.BROTLI_MODE_TEXT,
[zlib.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
[zlib.BROTLI_PARAM_SIZE_HINT]: raw.length,
},
})
: await gz(raw);
headers.set("Content-Encoding", coding);
ctx.response.body = new Uint8Array(compressed);
}

View File

@@ -10,9 +10,9 @@ import type { Playlist } from "../../shared/playlist.ts";
/**
* Returned by the playlist and follow services' `SELECT`s on `playlists`,
* which join the owner's name as `owner_username`. A query may add a
* `dump_count` column, which {@link playlistRowToApi} carries over when
* present.
* which join the owner's name as `owner_username`. A query may add the
* `dump_count` and `last_dump_at` columns (`VISIBLE_DUMP_FIGURES`), which
* {@link playlistRowToApi} carries over when present.
*/
export interface PlaylistRow {
id: string;
@@ -62,6 +62,9 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
lastDumpAt: typeof row.last_dump_at === "string"
? new Date(row.last_dump_at)
: undefined,
ownerUsername: row.owner_username,
};
}

View File

@@ -3,12 +3,15 @@
* http, which an https page would block, and those behind hotlink protection.
* It fetches and caches the images `GET /api/remote/image` serves, which the
* frontend routes a thumbnail through when it is on http or when loading it
* directly failed.
* directly failed, and scales them down as the site's own are.
*
* @module
*/
import { fetchWithTimeout, readBytesCapped } from "./fetch.ts";
import type { ImageWidth } from "../../shared/image.ts";
import { scaleImageBytes } from "../lib/ffmpeg.ts";
import { scalableImage } from "../lib/upload.ts";
/**
* Returned to `GET /api/remote/image` by {@link fetchProxiedImage}, which sends
@@ -34,9 +37,9 @@ const PROXYABLE_IMAGE_TYPES = new Set([
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
/**
* Proxied images, by URL, since every viewer of a feed asks for the same
* thumbnails. The least recently used go first, and the Map's order is that
* of use.
* Proxied images, by URL and by the width they were scaled to, since every
* viewer of a feed asks for the same thumbnails. The least recently used go
* first, and the Map's order is that of use.
*/
const imageCache = new Map<
string,
@@ -89,15 +92,33 @@ export function clearImageCache(): void {
/**
* Returns the image at `url`, from the cache or else from its host; null when
* the host answers with an error, or with anything but a raster image of 5 MB
* at most. The route
* checks `url` with `isValidHttpUrl` first.
* at most. With `width`, it comes as a WebP copy scaled down to it, or as it
* is when no copy can be made (`scalableImage`, and ffmpeg). The route checks
* `url` with `isValidHttpUrl` first.
*
* @throws {Error} When the host can't be reached, as
* {@link fetchWithTimeout} reports it.
*/
export async function fetchProxiedImage(
url: string,
width?: ImageWidth | null,
): Promise<ProxiedImage | null> {
if (!width) return await fetchOriginal(url);
const key = `${url}#w${width}`;
const cached = readImageCache(key);
if (cached) return cached;
const original = await fetchOriginal(url);
if (!original) return null;
const how = scalableImage(original.bytes);
const bytes = how && await scaleImageBytes(original.bytes, width, how);
if (!bytes) return original;
writeImageCache(key, bytes, "image/webp");
return { bytes, contentType: "image/webp" };
}
/** Returns the image at `url` as its host sends it: see above. */
async function fetchOriginal(url: string): Promise<ProxiedImage | null> {
const cached = readImageCache(url);
if (cached) return cached;

View File

@@ -4,9 +4,10 @@
*
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
* most 5 MB) and answers its id.
* - `GET /api/attachments/:attachmentId?token=` — public, but an image in a
* private dump or playlist, or in a comment on a private dump, needs its
* owner's media `token`. Serves the image.
* - `GET /api/attachments/:attachmentId?w=&token=` — public, but an image in
* a private dump or playlist, or in a comment on a private dump, needs its
* owner's media `token`. Serves the image, scaled down to `w`
* (`shared/image.ts`) when it names one.
*
* @module
*/

View File

@@ -1,14 +1,14 @@
/**
* Stores and serves the users' avatars. The profile page uploads the avatar,
* and the client's `Avatar` component loads it, with the user's `updatedAt` as
* a cache-busting version.
* and the client's `Avatar` component loads it at the size it shows, with the
* user's `updatedAt` as a cache-busting version.
*
* - `PUT /api/me/avatar` — signed in. Replaces the caller's avatar (multipart
* `file`, at most 5 MB), sends `user_updated` to everyone, and answers the
* caller's user. 400 for an account from another instance, whose avatar
* comes from there. The instances the caller signed in to are told.
* - `GET /api/users/:key/avatar` — public. Serves a user's avatar; 404 without
* one.
* - `GET /api/users/:key/avatar?w=` — public. Serves a user's avatar, scaled
* down to `w` (`shared/image.ts`) when it names one; 404 without one.
*
* @module
*/

View File

@@ -1,20 +1,25 @@
/**
* Serves the figures of the client's `/community` page, which
* `community-service.ts` recomputes on a timer.
* `community-service.ts` recomputes on a timer, and the community feed.
*
* - `GET /api/community?period=weekly|monthly` — signed in. Answers the
* site's figures over the last week (by default) or month.
* - `GET /api/community/feed` — signed in. Answers the latest things done in
* public and the week's figures.
*
* @module
*/
import { Router } from "@oak/oak";
import type { APIResponse } from "../../shared/api.ts";
import type { CommunityStats } from "../../shared/community.ts";
import type { CommunityFeed, CommunityStats } from "../../shared/community.ts";
import { isDigestCadence } from "../../shared/settings.ts";
import { APIException } from "../lib/api-exception.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { getCommunityStats } from "../services/community-service.ts";
import {
getCommunityFeed,
getCommunityStats,
} from "../services/community-service.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
@@ -34,4 +39,12 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
ctx.response.body = responseBody;
});
router.get("/feed", authMiddleware, (ctx: AuthContext) => {
const responseBody: APIResponse<CommunityFeed> = {
success: true,
data: getCommunityFeed(),
};
ctx.response.body = responseBody;
});
export default router;

View File

@@ -3,9 +3,11 @@
* playlist's id; its page reads it by its path, through
* `GET /api/users/:key/playlists/:slug`.
*
* - `GET /api/playlists?category=&page=&limit=` — public. Answers a page of
* every public playlist, newest first, or only those in the playlist
* category at `category`, which 404s when there is none.
* - `GET /api/playlists?category=&sort=&page=&limit=` — public. Answers a
* page of every public playlist, in the order `sort` names
* (`PLAYLIST_SORTS`, the last to take a dump first by default), or only
* those in the playlist category at `category`, which 404s when there is
* none.
* - `POST /api/playlists` — signed in. Creates a playlist, filed under the
* playlist categories it names.
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
@@ -24,9 +26,9 @@
* or with `playlist:moderate`. Removes a dump.
* - `PUT /api/playlists/:playlistId/cover` — signed in, as its owner or with
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
* - `GET /api/playlists/:playlistId/cover?token=` — public, but a private
* playlist's cover needs its owner's media `token`. Serves its cover, or 404s
* without one.
* - `GET /api/playlists/:playlistId/cover?w=&token=` — public, but a private
* playlist's cover needs its owner's media `token`. Serves its cover, scaled
* down to `w` (`shared/image.ts`) when it names one, or 404s without one.
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
* `playlist:moderate`. Reorders the dumps the caller can see in it.
*
@@ -44,7 +46,11 @@ import {
isReorderPlaylistRequest,
isUpdatePlaylistRequest,
} from "../validators/playlist.ts";
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
import {
parsePlaylistSort,
type Playlist,
type PlaylistWithDumps,
} from "../../shared/playlist.ts";
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";
import {
@@ -77,12 +83,19 @@ const router: Router<AuthState> = new Router<AuthState>({
prefix: "/api/playlists",
});
router.get("/", (ctx) => {
router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx);
const params = ctx.request.url.searchParams;
const { page, limit } = parsePagination(params);
const slug = params.get("category");
const categoryId = slug ? getCategoryBySlug("playlist", slug).id : undefined;
const { items, hasMore } = listPublicPlaylists(page, limit, categoryId);
const { items, hasMore } = listPublicPlaylists(
requestingUserId,
page,
limit,
parsePlaylistSort(params.get("sort")),
categoryId,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },

View File

@@ -6,9 +6,9 @@
*
* - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link,
* and whether its page could be reached.
* - `GET /api/remote/image?url=` — public. Serves a remote raster image
* through this site; 400 when it isn't one, 502 when its host can't be
* reached.
* - `GET /api/remote/image?url=&w=` — public. Serves a remote raster image
* through this site, scaled down to `w` (`shared/image.ts`) when it names
* one; 400 when it isn't an image, 502 when its host can't be reached.
* - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp
* page's tracks and their stream URLs, for the native player; `force=1`
* skips the server's cache.
@@ -19,6 +19,7 @@
*/
import { Router } from "@oak/oak";
import { parseImageWidth } from "../../shared/image.ts";
import { isValidHttpUrl } from "../remote/fetch.ts";
import { fetchProxiedImage } from "../remote/image-proxy.ts";
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
@@ -88,7 +89,10 @@ router.get("/image", async (ctx) => {
}
try {
const image = await fetchProxiedImage(url);
const image = await fetchProxiedImage(
url,
parseImageWidth(ctx.request.url.searchParams.get("w")),
);
if (!image) {
ctx.response.status = 400;
return;

View File

@@ -5,11 +5,12 @@
* from an upload are generated on first request by lib/ffmpeg.ts, which keeps
* them.
*
* - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's
* thumbnail needs its poster's media `token`. Serves the dump's custom
* thumbnail, else one made from its upload: a still of a video, or a
* scaled-down copy of an image (the image itself when none can be made); 404
* when there is neither.
* - `GET /api/dumps/:dumpId/thumbnail?v=&w=&token=` — public; a private
* dump's thumbnail needs its poster's media `token`. Serves the dump's
* custom thumbnail, else one made from its upload: a still of a video, or
* its image. An image comes scaled down to `w` (`shared/image.ts`), or to
* the widest of those widths without one, and as it is when no copy can be
* made; 404 when there is neither.
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
* MB).
@@ -32,18 +33,14 @@ import {
import { serveFile } from "../lib/serve-file.ts";
import {
replaceUploadedFile,
serveImage,
serveUploadedFile,
validateImageUpload,
} from "../lib/upload.ts";
import { DUMPS_DIR } from "../config.ts";
import { MAX_IMAGE_WIDTH } from "../../shared/image.ts";
import { resolveMediaDump } from "../lib/dump-media.ts";
import {
ensureGenerated,
generatedPaths,
imagePreview,
PREVIEWABLE_IMAGES,
videoStill,
} from "../lib/ffmpeg.ts";
import { ensureGenerated, generatedPaths, videoStill } from "../lib/ffmpeg.ts";
import { can } from "../lib/permissions.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
@@ -69,6 +66,7 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
dump.thumbnailMime,
0,
dump.isPrivate,
MAX_IMAGE_WIDTH,
);
if (served) return;
}
@@ -92,29 +90,12 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
) return;
}
if (mime.startsWith("image/")) {
// A scaled-down copy for the feed; the original when there's none to make
// (a GIF, no ffmpeg, an image ffmpeg can't read).
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
const ext = PREVIEWABLE_IMAGES[mime];
if (ext) {
const previewPath = generated.preview[ext];
if (
await ensureGenerated(
previewPath,
() => imagePreview(srcPath, previewPath),
) &&
await serveFile(ctx, previewPath, {
mime: ext === "png" ? "image/png" : "image/jpeg",
cacheControl: generatedCache,
})
) return;
}
if (
/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) &&
await serveFile(ctx, srcPath, { mime, cacheControl: generatedCache })
) return;
}
// A scaled-down copy for the feed; the original when there's none to make
// (no ffmpeg, an image it can't read).
if (
/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) &&
await serveImage(ctx, srcPath, mime, generatedCache, MAX_IMAGE_WIDTH)
) return;
throw new APIException(404, "No thumbnail for this dump");
});

View File

@@ -19,9 +19,10 @@
* - `GET /api/users/:key/upvoted?page=&limit=&category=` — optional sign-in.
* Answers a page of the dumps they upvoted, private ones only to
* themselves, optionally in one category.
* - `GET /api/users/:key/playlists?page=&limit=&category=` — optional
* - `GET /api/users/:key/playlists?page=&limit=&category=&sort=` — optional
* sign-in. Answers a page of the playlists they made, the private ones only
* to themselves, optionally in one playlist category.
* to themselves, optionally in one playlist category, in the order `sort`
* names, as `GET /api/playlists` does.
* - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their
* playlist whose slug is or was `slug`, with its current slug and the dumps
* the caller may see; a private one 404s but for its owner.
@@ -56,7 +57,11 @@ import {
type UserSuggestion,
} from "../../shared/user.ts";
import type { Dump } from "../../shared/dump.ts";
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
import {
parsePlaylistSort,
type Playlist,
type PlaylistWithDumps,
} from "../../shared/playlist.ts";
import {
fieldOf,
isRegisterUserRequest,
@@ -210,7 +215,14 @@ router.get("/:key/playlists", async (ctx) => {
const categoryId = resolveCategoryFilter("playlist", params);
const { items, hasMore } = categoryId === "missing"
? { items: [], hasMore: false }
: listPlaylistsByUser(user.id, requestingUserId, page, limit, categoryId);
: listPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
parsePlaylistSort(params.get("sort")),
categoryId,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
@@ -224,7 +236,7 @@ router.get("/:key/playlists/:slug", async (ctx) => {
const responseBody: APIResponse<PlaylistWithDumps> = {
success: true,
data: getPlaylist(
getPlaylistByPath(user.id, ctx.params.slug),
getPlaylistByPath(user.id, ctx.params.slug, requestingUserId),
requestingUserId,
),
};
@@ -245,10 +257,16 @@ router.get("/:key/follow/users", (ctx) => {
ctx.response.body = responseBody;
});
router.get("/:key/follow/playlists", (ctx) => {
router.get("/:key/follow/playlists", async (ctx) => {
const user = resolveUserKey(ctx.params.key);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit);
const { items, hasMore } = getFollowedPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },

View File

@@ -1,7 +1,8 @@
/**
* Records which dumps a dump's note and comments link to, and finds the dumps
* related to one through those links. The dump and comment services keep the
* links up to date, and the dump page lists the related dumps.
* links up to date, and the dump page lists the related dumps, which it
* fetches again when `dump_related_changed` says they changed.
*
* @module
*/
@@ -16,6 +17,7 @@ import {
import { parsePagePath } from "../../shared/paths.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { parseRows } from "../model/rows.ts";
import { broadcastDumpRelatedChanged } from "./ws-service.ts";
/**
* Matches an http(s) URL in Markdown. It stops at the delimiters of
@@ -46,6 +48,42 @@ function resolveDumpPathId(pathname: string): string | null {
return row?.id ?? null;
}
/**
* Tells clients that the links between `dumpId` and each of `otherIds`
* changed, as `broadcastDumpRelatedChanged` says, after reading who may see
* each of them. It sends nothing without `otherIds`.
*/
function announceRelatedChange(dumpId: string, otherIds: string[]): void {
if (otherIds.length === 0) return;
const ids = [dumpId, ...otherIds];
const rows = db.prepare(
`SELECT id, user_id, is_private FROM dumps
WHERE id IN (${ids.map(() => "?").join(", ")});`,
).all(...ids) as { id: string; user_id: string; is_private: number }[];
const dumps = rows.map((row) => ({
id: row.id,
userId: row.user_id,
isPrivate: Boolean(row.is_private),
}));
const dump = dumps.find((d) => d.id === dumpId);
if (!dump) return;
broadcastDumpRelatedChanged(dump, dumps.filter((d) => d.id !== dumpId));
}
/**
* Tells clients that dump `dumpId`, which just turned public, is now related
* to the dumps linked to or from it, for the dump service. Its poster saw
* those links already; to everyone else they are new.
*/
export function announceLinksNowPublic(dumpId: string): void {
const rows = db.prepare(
`SELECT to_dump_id AS id FROM dump_backlinks WHERE from_dump_id = ?
UNION
SELECT from_dump_id FROM dump_backlinks WHERE to_dump_id = ?;`,
).all(dumpId, dumpId) as { id: string }[];
announceRelatedChange(dumpId, rows.map((row) => row.id));
}
/**
* Replaces the backlinks from one text, a dump's note (`dump_comment`) or one
* of its comments (`comment`), with the links its current `text` holds. A link
@@ -53,7 +91,8 @@ function resolveDumpPathId(pathname: string): string | null {
* when it is the URL a dump was posted with, compared by `canonicalizeUrl` as
* reposts are. The dump and
* comment services call it on every save, and with an empty `text` when a
* comment is deleted.
* comment is deleted. Clients are told of the dumps it links or no longer
* links, as `dump_related_changed`.
*/
export function relinkBacklinks(
sourceType: "dump_comment" | "comment",
@@ -90,7 +129,11 @@ export function relinkBacklinks(
targetIds.delete(fromDumpId);
transaction(() => {
const formerIds = transaction(() => {
const former = db.prepare(
`SELECT to_dump_id FROM dump_backlinks
WHERE source_type = ? AND source_id = ?;`,
).all(sourceType, sourceId) as { to_dump_id: string }[];
db.prepare(
`DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`,
).run(sourceType, sourceId);
@@ -102,7 +145,13 @@ export function relinkBacklinks(
for (const toDumpId of targetIds) {
insert.run(sourceType, sourceId, fromDumpId, toDumpId, createdAt);
}
return new Set(former.map((row) => row.to_dump_id));
});
// Most saves change no link, and send nothing.
announceRelatedChange(fromDumpId, [
...targetIds.symmetricDifference(formerIds),
]);
}
/**

View File

@@ -5,18 +5,28 @@
* the month ending at the last refresh, which {@link communityJob} redoes
* every {@link COMMUNITY_REFRESH_MINUTES}, so a visit runs no query.
*
* It also gathers the community feed ({@link getCommunityFeed}), the latest
* things done in public, which is read from the tables on each request: it
* has to show a dump the moment it is posted.
*
* @module
*/
import { db } from "../db/connection.ts";
import { COMMUNITY_REFRESH_MINUTES } from "../config.ts";
import type { CommunityStats } from "../../shared/community.ts";
import type {
CommunityEvent,
CommunityFeed,
CommunityItem,
CommunityStats,
} from "../../shared/community.ts";
import {
DIGEST_CADENCES,
type DigestCadence,
PERIOD_MS,
} from "../../shared/settings.ts";
import type { Job } from "../lib/scheduler.ts";
import { parseRows } from "../model/rows.ts";
import { listTopDumps } from "./dump-service.ts";
import { listNewPublicPlaylists } from "./playlist-service.ts";
import { listNewUsers } from "./user-service.ts";
@@ -91,3 +101,148 @@ export const communityJob: Job = {
firstAfterMs: 0,
run: () => refreshCommunityStats(),
};
/** How many events the feed holds, and how many of each kind it reads. */
const FEED_MAX = 12;
/**
* A row of {@link listCommunityEvents}'s query, whose five parts share these
* columns. `title`, `slug` and `owner_username` name the dump or the playlist
* of every event but a `user_joined`.
*/
interface EventRow {
type: CommunityEvent["type"];
/** The dump, the playlist or the new user. */
subject_id: string;
at: string;
username: string;
title: string | null;
slug: string | null;
owner_username: string | null;
comment_id: string | null;
invited_by: string | null;
}
/** Checks `row`'s columns that every event has. */
function isEventRow(row: unknown): row is EventRow {
const r = row as Partial<EventRow> | null;
return !!r && typeof r.type === "string" &&
typeof r.subject_id === "string" && typeof r.at === "string" &&
typeof r.username === "string" &&
(r.type === "user_joined" ||
(typeof r.title === "string" && typeof r.slug === "string" &&
typeof r.owner_username === "string"));
}
function eventOf(row: EventRow): CommunityEvent {
const base = {
id: `${row.type}:${row.subject_id}`,
at: new Date(row.at),
username: row.username,
};
// `isEventRow` has checked the three on every row that reads `item`.
const item: CommunityItem = {
title: row.title!,
slug: row.slug!,
ownerUsername: row.owner_username!,
};
switch (row.type) {
case "dump_posted":
case "dump_upvoted":
return { ...base, type: row.type, dump: item };
case "dump_commented":
return {
...base,
type: row.type,
dump: item,
commentId: row.comment_id!,
};
case "playlist_created":
return { ...base, type: row.type, playlist: item };
case "user_joined":
return {
...base,
type: row.type,
invitedByUsername: row.invited_by ?? undefined,
};
}
}
/**
* Returns the latest things done in public, newest first, {@link FEED_MAX} at
* most: the public dumps posted and playlists created, the comments and
* upvotes on public dumps, and who joined. A dump has one comment event and
* one upvote event at most, its latest, so that a thread or a popular dump
* doesn't fill the feed.
*
* Each part reads its {@link FEED_MAX} latest, which is all the whole may
* keep of it. The comments and the upvotes are grouped by dump first, and
* with `MAX()` SQLite fills a group's other columns from the row that holds
* the maximum: the latest comment's author and id, the latest voter.
*
* @throws {APIException} 500 when a row doesn't parse as an event.
*/
export function listCommunityEvents(): CommunityEvent[] {
const rows = db.prepare(
`SELECT * FROM (
SELECT 'dump_posted' AS type, d.id AS subject_id, d.created_at AS at,
u.username AS username, d.title AS title, d.slug AS slug,
u.username AS owner_username, NULL AS comment_id,
NULL AS invited_by
FROM dumps d JOIN users u ON u.id = d.user_id
WHERE d.is_private = 0
ORDER BY d.created_at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'playlist_created', p.id, p.created_at, u.username, p.title,
p.slug, u.username, NULL, NULL
FROM playlists p JOIN users u ON u.id = p.user_id
WHERE p.is_public = 1
ORDER BY p.created_at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'dump_commented', d.id, MAX(c.created_at) AS at, cu.username,
d.title, d.slug, ou.username, c.id, NULL
FROM comments c
JOIN dumps d ON d.id = c.dump_id
JOIN users cu ON cu.id = c.user_id
JOIN users ou ON ou.id = d.user_id
WHERE c.deleted = 0 AND d.is_private = 0
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'dump_upvoted', d.id, MAX(v.created_at) AS at, vu.username,
d.title, d.slug, ou.username, NULL, NULL
FROM votes v
JOIN dumps d ON d.id = v.dump_id
JOIN users vu ON vu.id = v.user_id
JOIN users ou ON ou.id = d.user_id
WHERE d.is_private = 0
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'user_joined', u.id, u.created_at, u.username, NULL, NULL, NULL,
NULL, i.username
FROM users u LEFT JOIN users i ON i.id = u.invited_by
ORDER BY u.created_at DESC LIMIT ${FEED_MAX})
ORDER BY at DESC LIMIT ${FEED_MAX};`,
).all();
return parseRows(rows, isEventRow, eventOf);
}
/**
* Returns the community feed for `GET /api/community/feed`: the latest
* events, read now, and the last week's figures as of the last refresh.
*/
export function getCommunityFeed(): CommunityFeed {
const { totals, chatMessages, top } = getCommunityStats("weekly");
const [best] = top;
return {
events: listCommunityEvents(),
week: {
totals,
chatMessages,
top: best && {
title: best.title,
slug: best.slug,
ownerUsername: best.ownerUsername,
},
},
};
}

View File

@@ -25,6 +25,7 @@ import {
} from "../../shared/settings.ts";
import type { CommunityStats } from "../../shared/community.ts";
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
import type { ImageWidth } from "../../shared/image.ts";
import { siteHost } from "../../shared/url.ts";
import { markdownToPlainText } from "../../shared/markdown.ts";
import {
@@ -101,11 +102,19 @@ function quoteOf(markdown: string | undefined | null): string | undefined {
: flat;
}
/**
* The width the emails' thumbnails are asked at: the widest an email shows
* one, on a dense screen.
*/
const EMAIL_IMAGE_WIDTH: ImageWidth = 640;
/**
* Returns the image a mail client can load for `dump`, which must be public:
* the thumbnails route for a custom thumbnail or an image or video upload,
* else the link preview's image, through the image proxy when it is plain
* http, which a mail client may refuse.
* scaled to the email's width as a JPEG or PNG (`f=compat`), since not every
* mail client reads the WebP a browser gets; else the link preview's image,
* through the image proxy when it is plain http, which a mail client may
* refuse.
*/
function thumbnailOf(dump: Dump): string | null {
const isMedia = dump.kind === "file" &&
@@ -113,7 +122,7 @@ function thumbnailOf(dump: Dump): string | null {
if (dump.thumbnailMime || isMedia) {
return `${PUBLIC_URL}/api/dumps/${dump.id}/thumbnail?v=${
dumpFileVersion(dump)
}`;
}&w=${EMAIL_IMAGE_WIDTH}&f=compat`;
}
const thumbnailUrl = dump.richContent?.thumbnailUrl;
if (typeof thumbnailUrl !== "string") return null;

View File

@@ -45,7 +45,7 @@ import {
} from "./notification-service.ts";
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { removeGeneratedMedia } from "../lib/ffmpeg.ts";
import { removeGeneratedMedia, removeScaledImages } from "../lib/ffmpeg.ts";
import {
DUMP_ALLOWED_MIME_PREFIXES,
DUMP_ALLOWED_MIME_TYPES,
@@ -57,7 +57,7 @@ import {
VALIDATION,
} from "../../shared/validation.ts";
import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
import { announceLinksNowPublic, relinkBacklinks } from "./backlink-service.ts";
import {
assertCategoriesExist,
attachCategoryIds,
@@ -96,7 +96,8 @@ function assertFileDumpText(title?: string, comment?: string): void {
* `broadcastDumpChange` for the dump itself, and, when it was deleted or
* changed visibility, the dumps of each playlist holding it, which moved for
* their viewers. A deletion passes the `holders` it read before the cascade
* took the dump out of them.
* took the dump out of them. A dump that turned public also joins the related
* dumps of those it is linked with.
*/
function announceDumpChange(
before: Dump | null,
@@ -106,6 +107,7 @@ function announceDumpChange(
broadcastDumpChange(before, after);
if (!before || (after && after.isPrivate === before.isPrivate)) return;
broadcastHoldersDumps(holders ?? playlistsHolding(before.id));
if (after && !after.isPrivate) announceLinksNowPublic(after.id);
}
/**
@@ -852,6 +854,13 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
return updatedDump;
}
/** Deletes the file of dump `dumpId`'s custom thumbnail, and its copies. */
async function removeCustomThumbnail(dumpId: string): Promise<void> {
const path = customThumbnailPath(dumpId);
await Deno.remove(path).catch(() => {});
await removeScaledImages(path);
}
/**
* Removes a dump's custom thumbnail and its file, for
* `DELETE /api/dumps/:dumpId/thumbnail`.
@@ -862,7 +871,7 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
.run(dump.id);
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
await removeCustomThumbnail(dump.id);
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
announceDumpChange(dump, updatedDump);
return updatedDump;
@@ -889,9 +898,7 @@ export async function deleteDump(dumpId: string): Promise<void> {
await Deno.remove(`${DUMPS_DIR}/${dump.id}`).catch(() => {});
await removeGeneratedMedia(dump.id);
}
if (dump.thumbnailMime) {
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
}
if (dump.thumbnailMime) await removeCustomThumbnail(dump.id);
announceDumpChange(dump, null, holders);
}

View File

@@ -23,6 +23,7 @@ import {
} from "../model/dump.ts";
import { isFollowRow } from "../model/follow.ts";
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { VISIBLE_DUMP_FIGURES } from "../lib/playlist-members.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
import { sendToUser } from "./ws-service.ts";
@@ -232,12 +233,14 @@ export function getFollowedPlaylistsDumpFeed(
/**
* Returns a page of the public playlists `userId` follows, most recently
* followed first, for their profile and playlists pages.
* followed first, for their profile and playlists pages. Their dumps are
* counted for `requestingUserId`, null being a signed-out viewer.
*
* @throws {APIException} 500 when a row doesn't parse as a playlist.
*/
export function getFollowedPlaylistsByUser(
userId: string,
requestingUserId: string | null,
page: number,
limit: number,
): Page<Playlist> {
@@ -245,7 +248,7 @@ export function getFollowedPlaylistsByUser(
const rawRows = db.prepare(
`SELECT p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
${VISIBLE_DUMP_FIGURES}
FROM playlists p
LEFT JOIN users u ON u.id = p.user_id
INNER JOIN follows f ON f.followed_playlist_id = p.id
@@ -253,7 +256,7 @@ export function getFollowedPlaylistsByUser(
AND p.is_public = 1
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?;`,
).all(userId, limit + 1, offset);
).all(requestingUserId ?? "", userId, limit + 1, offset);
if (!rawRows.every(isPlaylistRow)) {
throw new APIException(500, "Malformed playlist data");

View File

@@ -12,6 +12,7 @@ import type {
CreatePlaylistRequest,
Playlist,
PlaylistMembership,
PlaylistSort,
PlaylistWithDumps,
UpdatePlaylistRequest,
} from "../../shared/playlist.ts";
@@ -42,21 +43,41 @@ import {
setCategories,
} from "./category-service.ts";
import {
type DatedPlaylistMember,
isMemberVisibleTo,
type PlaylistMember,
VISIBLE_DUMP_FIGURES,
} from "../lib/playlist-members.ts";
import { LIKE, likePattern } from "../lib/like.ts";
import { parseRows } from "../model/rows.ts";
/**
* The select list and `FROM` clause of a playlist query, adding the owner's
* name and the dump count. Callers append the `WHERE`. The dump count includes
* dumps the viewer can't see.
* name, the count of the dumps the viewer may see and when the latest was
* added. Callers append the `WHERE`, and pass the viewer's id first, as
* {@link viewerParam} gives it.
*/
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
${VISIBLE_DUMP_FIGURES}
FROM playlists p JOIN users u ON u.id = p.user_id`;
/**
* The `ORDER BY` of each of the `PLAYLIST_SORTS`, on a {@link PLAYLIST_SELECT}:
* `comparePlaylists` (`shared/playlist.ts`) in SQL, which a test holds to it.
*/
const PLAYLIST_ORDER: Record<PlaylistSort, string> = {
updated: "COALESCE(last_dump_at, p.created_at) DESC, p.created_at DESC",
new: "p.created_at DESC",
dumps: "dump_count DESC, p.created_at DESC",
};
/**
* Returns what {@link VISIBLE_DUMP_FIGURES}'s parameter takes for `viewerId`:
* a signed-out viewer matches no user.
*/
function viewerParam(viewerId: string | null | undefined): string {
return viewerId ?? "";
}
/**
* Converts the rows of a {@link PLAYLIST_SELECT} to playlists, their
* categories filled in.
@@ -69,13 +90,18 @@ function playlistsOf(rows: unknown[]): Playlist[] {
/**
* Returns playlist `playlistId`, private or not. A caller that shows it to
* someone must check `isPublic` first, as the OG middleware does.
* someone must check `isPublic` first, as the OG middleware does. Its
* `dumpCount` and `lastDumpAt` are `viewerId`'s, or a signed-out viewer's
* without one.
*
* @throws {APIException} 404 when there is no such playlist.
*/
export function getPlaylistById(playlistId: string): Playlist {
export function getPlaylistById(
playlistId: string,
viewerId: string | null = null,
): Playlist {
const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`)
.get(playlistId);
.get(viewerParam(viewerId), playlistId);
if (!row || !isPlaylistRow(row)) {
throw new APIException(404, "Playlist not found");
}
@@ -86,13 +112,18 @@ export function getPlaylistById(playlistId: string): Playlist {
/**
* Returns the playlist of `userId`'s whose slug is or was `slug`, private or
* not, or throws a 404, as {@link getPlaylistById} does. Reached by a former
* slug, it comes with its current one.
* not, or throws a 404, as {@link getPlaylistById} does, counting its dumps
* for `viewerId` likewise. Reached by a former slug, it comes with its current
* one.
*/
export function getPlaylistByPath(userId: string, slug: string): Playlist {
export function getPlaylistByPath(
userId: string,
slug: string,
viewerId: string | null = null,
): Playlist {
const playlistId = resolveSlug("playlist", userId, slug);
if (!playlistId) throw new APIException(404, "Playlist not found");
return getPlaylistById(playlistId);
return getPlaylistById(playlistId, viewerId);
}
/**
@@ -136,7 +167,7 @@ export function createPlaylist(
}
linkAttachments(req.description, "playlist", id);
}
broadcastPlaylistChange(null, playlist);
broadcastPlaylistChange(null, playlist, []);
return playlist;
}
@@ -144,7 +175,8 @@ export function createPlaylist(
* Returns a playlist with the dumps `requestingUserId` may see in it, in
* order, for `GET /api/playlists/:playlistId`, and for
* `GET /api/users/:key/playlists/:slug` once it has found the playlist by its
* path. A null `requestingUserId` is a signed-out viewer.
* path, for the same viewer. A null `requestingUserId` is a signed-out viewer.
* Its `dumpCount` is the number of those dumps.
*
* @throws {APIException} 404 when there is no such playlist, or it is private
* and not theirs.
@@ -154,7 +186,7 @@ export function getPlaylist(
requestingUserId: string | null,
): PlaylistWithDumps {
const playlist = typeof playlistId === "string"
? getPlaylistById(playlistId)
? getPlaylistById(playlistId, requestingUserId)
: playlistId;
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
@@ -175,7 +207,7 @@ export function getPlaylist(
isMemberVisibleTo(d, playlist, requestingUserId)
);
return { ...playlist, dumps: visibleDumps };
return { ...playlist, dumpCount: visibleDumps.length, dumps: visibleDumps };
}
/**
@@ -198,22 +230,33 @@ export function searchPlaylists(
const rows = requestingUserId
? db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(...catParams, requestingUserId, pattern, pattern, limit)
).all(
requestingUserId,
...catParams,
requestingUserId,
pattern,
pattern,
limit,
)
: db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(...catParams, pattern, pattern, limit);
).all(viewerParam(null), ...catParams, pattern, pattern, limit);
return playlistsOf(rows);
}
/**
* Returns a page of every public playlist, newest first, for
* Returns a page of every public playlist, in the order `sort` names, for
* `GET /api/playlists`, optionally only those in the playlist category
* `categoryId`. The client's `/playlists` page lists them.
* `categoryId`. The client's `/playlists` page lists them. Their dumps are
* counted, and so ordered, for `requestingUserId`, null being a signed-out
* viewer.
*/
export function listPublicPlaylists(
requestingUserId: string | null,
page: number,
limit: number,
sort: PlaylistSort,
categoryId?: string,
): Page<Playlist> {
const offset = (page - 1) * limit;
@@ -221,27 +264,28 @@ export function listPublicPlaylists(
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.is_public = 1
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
).all(...catParams, limit + 1, offset);
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
).all(viewerParam(requestingUserId), ...catParams, limit + 1, offset);
return pageOf(playlistsOf(rows), limit);
}
/**
* Returns the public playlists created from `from` to `to`, the fullest first
* and the newer on a tie. The community service gathers them for the
* `/community` page and the digests.
* `/community` page and the digests, which show every reader the same
* figures: only their public dumps are counted.
*/
export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] {
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT}
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ?
ORDER BY dump_count DESC, p.created_at DESC;`,
).all(from.toISOString(), to.toISOString());
ORDER BY ${PLAYLIST_ORDER.dumps};`,
).all(viewerParam(null), from.toISOString(), to.toISOString());
return playlistsOf(rows);
}
/**
* Returns a page of `userId`'s playlists, newest first, for
* Returns a page of `userId`'s playlists, in the order `sort` names, for
* `GET /api/users/:key/playlists`, optionally only those in the playlist
* category `categoryId`. The private ones are listed only when
* `requestingUserId` is `userId`.
@@ -251,6 +295,7 @@ export function listPlaylistsByUser(
requestingUserId: string | null,
page: number,
limit: number,
sort: PlaylistSort,
categoryId?: string,
): Page<Playlist> {
const isOwner = requestingUserId === userId;
@@ -261,8 +306,14 @@ export function listPlaylistsByUser(
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.user_id = ?${privacyFilter}
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
).all(...catParams, userId, limit + 1, offset);
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
).all(
viewerParam(requestingUserId),
...catParams,
userId,
limit + 1,
offset,
);
return pageOf(playlistsOf(rows), limit);
}
@@ -287,7 +338,7 @@ export function updatePlaylist(
requestingUserId: string,
canModerate: boolean,
): Playlist {
const playlist = getPlaylistById(playlistId);
const playlist = getPlaylistById(playlistId, requestingUserId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(403, "Only its owner or a moderator may do that");
@@ -347,7 +398,7 @@ export function updatePlaylist(
}
linkAttachments(newDescription, "playlist", playlist.id);
}
broadcastPlaylistChange(playlist, updated);
broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id));
// Its followers weren't told of the dumps added while it was private.
if (!playlist.isPublic && newIsPublic) {
notifyPlaylistFollowersNowPublic({ playlistId: playlist.id });
@@ -374,7 +425,7 @@ export function deletePlaylist(
}
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
broadcastPlaylistChange(playlist, null);
broadcastPlaylistChange(playlist, null, []);
}
/**
@@ -390,7 +441,7 @@ export function getEditablePlaylist(
requestingUserId: string,
canModerate: boolean,
): Playlist {
const playlist = getPlaylistById(playlistId);
const playlist = getPlaylistById(playlistId, requestingUserId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(403, "Only its owner or a moderator may do that");
}
@@ -421,8 +472,8 @@ export function setPlaylistImage(
db.prepare(
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
).run(imageMime, new Date().toISOString(), playlist.id);
const updated = getPlaylistById(playlist.id);
broadcastPlaylistChange(playlist, updated);
const updated = getPlaylistById(playlist.id, requestingUserId);
broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id));
return updated;
}
@@ -590,13 +641,13 @@ export function getPlaylistMembershipsForDump(
): PlaylistMembership[] {
const rows = db.prepare(
`SELECT p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps x WHERE x.playlist_id = p.id) as dump_count,
${VISIBLE_DUMP_FIGURES},
EXISTS (SELECT 1 FROM playlist_dumps pd
WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump
FROM playlists p JOIN users u ON u.id = p.user_id
WHERE p.user_id = ?
ORDER BY p.created_at DESC;`,
).all(dumpId, userId);
).all(userId, dumpId, userId);
const memberships = rows.map((row) => {
if (!isPlaylistRow(row)) {
@@ -626,7 +677,7 @@ export function getPlaylistsForDump(
INNER JOIN playlist_dumps pd ON pd.playlist_id = p.id
WHERE pd.dump_id = ? AND (p.is_public = 1 OR p.user_id = ?)
ORDER BY p.created_at DESC LIMIT 20;`,
).all(dumpId, requestingUserId ?? "");
).all(viewerParam(requestingUserId), dumpId, requestingUserId ?? "");
return playlistsOf(rows);
}
@@ -660,18 +711,19 @@ export function getPlaylistImageInfo(
* Returns a playlist's dumps in order, all of them, for the callers to filter
* per viewer.
*/
function getCurrentMembers(playlistId: string): PlaylistMember[] {
function getCurrentMembers(playlistId: string): DatedPlaylistMember[] {
const rows = db.prepare(
`SELECT pd.dump_id, d.is_private, d.user_id
`SELECT pd.dump_id, pd.added_at, d.is_private, d.user_id
FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id
WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`,
).all(playlistId) as Array<
{ dump_id: string; is_private: number; user_id: string }
{ dump_id: string; added_at: string; is_private: number; user_id: string }
>;
return rows.map((r) => ({
id: r.dump_id,
isPrivate: Boolean(r.is_private),
userId: r.user_id,
addedAt: new Date(r.added_at),
}));
}
@@ -701,7 +753,7 @@ export function playlistsHolding(dumpId: string): PlaylistHolder[] {
/**
* Sends each of `holders` its dumps again, after one of them was deleted or
* changed visibility: the count moved, or what each viewer may see of them.
* changed visibility, which changes what each viewer may see of them.
*/
export function broadcastHoldersDumps(holders: PlaylistHolder[]): void {
for (const holder of holders) {

View File

@@ -18,8 +18,9 @@ import type { OnlineUser, ServerToClientMessage } from "../../shared/ws.ts";
import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser, Role } from "../../shared/user.ts";
import {
type DatedPlaylistMember,
dumpFiguresFor,
isMemberVisibleTo,
type PlaylistMember,
} from "../lib/playlist-members.ts";
import type { Job } from "../lib/scheduler.ts";
@@ -179,35 +180,43 @@ export function broadcastPresence(): void {
* `before` is null for a new item, `after` for a deleted one. A private item's
* changes reach its owner's sockets alone. When it goes private, everyone else
* is told it was deleted, and gets nothing of the change; when it goes public,
* they are told it was created, since to them it is new.
* they are told it was created, since to them it is new. The owner's sockets
* and the others' each get a message of their own, built with `toOwner` set
* for the former, for what the two don't see alike.
*/
function broadcastChange<T extends { userId: string }>(
before: T | null,
after: T | null,
isPrivate: (item: T) => boolean,
messages: {
created: (item: T) => ServerToClientMessage;
updated: (item: T) => ServerToClientMessage;
created: (item: T, toOwner: boolean) => ServerToClientMessage;
updated: (item: T, toOwner: boolean) => ServerToClientMessage;
deleted: (item: T) => ServerToClientMessage;
},
): void {
const item = after ?? before;
if (!item) return;
const toOwner = (client: WsClient) => client.userId === item.userId;
const toOthers = (client: WsClient) => client.userId !== item.userId;
const wasPublic = !!before && !isPrivate(before);
const isPublic = !!after && !isPrivate(after);
if (!after) {
broadcast(messages.deleted(item), isPrivate(item) ? toOwner : undefined);
} else if (!before) {
broadcast(messages.created(after), isPrivate(after) ? toOwner : undefined);
} else if (isPrivate(after)) {
broadcast(messages.updated(after), toOwner);
if (!isPrivate(before)) broadcast(messages.deleted(after), toOthers);
} else if (isPrivate(before)) {
broadcast(messages.updated(after), toOwner);
broadcast(messages.created(after), toOthers);
} else {
broadcast(messages.updated(after));
broadcast(
!after
? messages.deleted(item)
: before
? messages.updated(after, true)
: messages.created(after, true),
(client) => client.userId === item.userId,
);
const toOthers = (client: WsClient) => client.userId !== item.userId;
if (after && isPublic) {
broadcast(
wasPublic
? messages.updated(after, false)
: messages.created(after, false),
toOthers,
);
} else if (wasPublic) {
broadcast(messages.deleted(item), toOthers);
}
}
@@ -226,6 +235,29 @@ export function broadcastDumpChange(
});
}
/**
* Sends `dump_related_changed` after the links between `dump` and each of
* `others` changed, for the backlink service. A link shows only to those who
* may see both its ends, so a socket is told only when its user may see
* `dump` and one of `others`, and of those alone.
*/
export function broadcastDumpRelatedChanged(
dump: Pick<Dump, "id" | "isPrivate" | "userId">,
others: Pick<Dump, "id" | "isPrivate" | "userId">[],
): void {
for (const client of clients) {
const sees = (d: Pick<Dump, "isPrivate" | "userId">) =>
!d.isPrivate || d.userId === client.userId;
if (!sees(dump)) continue;
const seen = others.filter(sees);
if (seen.length === 0) continue;
send(client.socket, {
type: "dump_related_changed",
dumpIds: [dump.id, ...seen.map((d) => d.id)],
});
}
}
/**
* Sends `votes_update` after the route stored a vote, to everyone, or with
* `onlyUserId` to that user's sockets alone (for a private dump).
@@ -265,15 +297,28 @@ export function broadcastCommentLikeUpdate(
* Tells the clients that may see a playlist how it changed, as
* {@link broadcastChange} says, for the playlist service after every write to
* its details. A playlist turned private thus leaves the others' views without
* their learning what it became.
* their learning what it became. `members` are the dumps of `after`: each
* socket gets the playlist with the count of those its user may see and the
* date of the latest, the owner's private dumps being theirs alone to count.
*/
export function broadcastPlaylistChange(
before: Playlist | null,
after: Playlist | null,
members: DatedPlaylistMember[],
): void {
const counted = (playlist: Playlist, toOwner: boolean): Playlist => ({
...playlist,
...dumpFiguresFor(members, playlist, toOwner ? playlist.userId : null),
});
broadcastChange(before, after, (playlist) => !playlist.isPublic, {
created: (playlist) => ({ type: "playlist_created", playlist }),
updated: (playlist) => ({ type: "playlist_updated", playlist }),
created: (playlist, toOwner) => ({
type: "playlist_created",
playlist: counted(playlist, toOwner),
}),
updated: (playlist, toOwner) => ({
type: "playlist_updated",
playlist: counted(playlist, toOwner),
}),
deleted: (p) => ({
type: "playlist_deleted",
playlistId: p.id,
@@ -285,11 +330,12 @@ export function broadcastPlaylistChange(
/**
* Sends `playlist_dumps_updated` to the playlist's audience, each socket with
* only the dumps its user may see, as `GET` would list them. The private dumps
* in a public playlist stay hidden from everyone but their posters.
* in a public playlist stay hidden from everyone but their posters, and so
* does their number: a client counts the dumps it is sent.
*/
export function broadcastPlaylistDumpsUpdated(
playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
members: PlaylistMember[],
members: DatedPlaylistMember[],
): void {
for (const client of clients) {
if (!playlist.isPublic && client.userId !== playlist.userId) continue;
@@ -299,7 +345,7 @@ export function broadcastPlaylistDumpsUpdated(
dumpIds: members
.filter((m) => isMemberVisibleTo(m, playlist, client.userId))
.map((m) => m.id),
dumpCount: members.length,
lastDumpAt: dumpFiguresFor(members, playlist, client.userId).lastDumpAt,
});
}
}

View File

@@ -388,6 +388,60 @@ export const PNG: Uint8Array<ArrayBuffer> = Uint8Array.from(
(c) => c.charCodeAt(0),
);
/** Whether ffmpeg runs here; the tests of what it makes are skipped without. */
export async function hasFfmpeg(): Promise<boolean> {
try {
return (await new Deno.Command("ffmpeg", {
args: ["-version"],
stdout: "null",
stderr: "null",
}).output()).success;
} catch {
return false;
}
}
/**
* Makes a real image with ffmpeg, a `width`×`height` field of `color`, as a
* PNG, or as a GIF of three frames.
*/
export async function makeImage(
width: number,
height: number,
color = "red",
format: "png" | "gif" = "png",
): Promise<Uint8Array<ArrayBuffer>> {
const { stdout } = await new Deno.Command("ffmpeg", {
args: [
"-f",
"lavfi",
"-i",
`color=c=${color}:size=${width}x${height}:rate=3`,
"-frames:v",
format === "gif" ? "3" : "1",
"-f",
format === "gif" ? "gif" : "image2pipe",
...(format === "gif" ? [] : ["-c:v", "png"]),
"-",
],
stdout: "piped",
stderr: "null",
}).output();
return new Uint8Array(stdout);
}
/** Returns the width, in pixels, a WebP file's header states. */
export function webpWidth(bytes: Uint8Array): number {
const kind = new TextDecoder().decode(bytes.subarray(12, 16));
// Lossy: the frame's start code, then 14 bits of width.
if (kind === "VP8 ") return (bytes[26] | (bytes[27] << 8)) & 0x3FFF;
// Lossless: a signature byte, then 14 bits of width minus one.
if (kind === "VP8L") return ((bytes[21] | (bytes[22] << 8)) & 0x3FFF) + 1;
// Extended, as an animation or an image with transparency: the canvas's
// width minus one, on 24 bits.
return (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1;
}
export function formWithFile(
bytes: Uint8Array<ArrayBuffer>,
name = "file.png",

View File

@@ -0,0 +1,62 @@
import { TEST_DIR } from "../setup.ts";
import { assertEquals } from "@std/assert";
import { sweepGeneratedMedia } from "../../lib/ffmpeg.ts";
const UPLOADS = `${TEST_DIR}/uploads`;
async function write(path: string, ageMs = 0): Promise<void> {
const full = `${UPLOADS}/${path}`;
await Deno.mkdir(full.slice(0, full.lastIndexOf("/")), { recursive: true });
await Deno.writeTextFile(full, "x");
if (ageMs > 0) {
const at = new Date(Date.now() - ageMs);
await Deno.utime(full, at, at);
}
}
const exists = (path: string) =>
Deno.stat(`${UPLOADS}/${path}`).then(() => true, () => false);
Deno.test("sweepGeneratedMedia: takes the copies of gone uploads, the old previews and abandoned temporary files", async () => {
const HOURS = 60 * 60_000;
const kept = [
// An upload and its copies, in each format.
"avatars/u1",
"scaled/avatars/u1.w128.webp",
"scaled/avatars/u1.w640.png",
// A custom thumbnail is an upload; a still and a waveform go with their
// dump, not here.
"thumbnails/d1-custom",
"scaled/thumbnails/d1-custom.w960.webp",
"thumbnails/d1.jpg",
"thumbnails/d1.waveform.json",
// Being written right now.
"scaled/avatars/u1.w320.webp.1234.tmp",
"scaled/tmp/fresh",
"thumbnails/d1.jpg.1234.tmp",
];
const swept = [
// Their upload is gone.
"scaled/avatars/u2.w128.webp",
"scaled/dumps/d2.w960.jpg",
// The name the previews of image dumps had.
"thumbnails/d1.preview.jpg",
"thumbnails/d3.preview.png",
];
const abandoned = [
"scaled/avatars/u1.w960.webp.5678.tmp",
"scaled/tmp/stale",
"scaled/tmp/stale.webp",
"thumbnails/d1.waveform.json.5678.tmp",
];
for (const path of [...kept, ...swept]) await write(path);
for (const path of abandoned) await write(path, 2 * HOURS);
assertEquals(await sweepGeneratedMedia(), swept.length + abandoned.length);
for (const path of kept) assertEquals(await exists(path), true, path);
for (const path of [...swept, ...abandoned]) {
assertEquals(await exists(path), false, path);
}
// Nothing left to take.
assertEquals(await sweepGeneratedMedia(), 0);
});

View File

@@ -1,7 +1,11 @@
import "../setup.ts";
import { PNG } from "../helpers.ts";
import { assertEquals, assertThrows } from "@std/assert";
import { detectImageMime, validateImageUpload } from "../../lib/upload.ts";
import {
detectImageMime,
scalableImage,
validateImageUpload,
} from "../../lib/upload.ts";
import { APIException } from "../../lib/api-exception.ts";
Deno.test("detectImageMime: sniffs magic bytes, not names", () => {
@@ -40,3 +44,77 @@ Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () =
big.set(PNG);
assertThrows(() => validateImageUpload(big), APIException, "too large");
});
/** A JPEG's first bytes: SOI, then an EXIF segment stating `orientation`. */
function jpegWithOrientation(orientation: number, little: boolean): Uint8Array {
const u16 = (n: number) => little ? [n & 0xff, n >> 8] : [n >> 8, n & 0xff];
const u32 = (n: number) => little ? [...u16(n), 0, 0] : [0, 0, ...u16(n)];
const tiff = [
...(little ? [0x49, 0x49] : [0x4d, 0x4d]),
...u16(42),
...u32(8),
...u16(1),
...u16(0x0112),
...u16(3),
...u32(1),
...u16(orientation),
...[0, 0],
...u32(0),
];
const exif = [0x45, 0x78, 0x69, 0x66, 0, 0, ...tiff];
const length = exif.length + 2;
return new Uint8Array([
...[0xff, 0xd8],
// A JFIF segment first, as a camera's file may have.
...[0xff, 0xe0, 0x00, 0x04, 0x00, 0x00],
...[0xff, 0xe1, length >> 8, length & 0xff],
...exif,
...[0xff, 0xda],
]);
}
Deno.test("scalableImage: a JPEG's EXIF orientation, upright without one", () => {
const turn = (data: Uint8Array) => scalableImage(data)?.orientation;
assertEquals(turn(jpegWithOrientation(6, false)), 6);
assertEquals(turn(jpegWithOrientation(8, true)), 8);
assertEquals(turn(jpegWithOrientation(42, true)), 1);
assertEquals(turn(new Uint8Array([0xff, 0xd8, 0xff, 0xda, 0, 2])), 1);
// Cut short of what it announces: upright, not an error.
assertEquals(turn(jpegWithOrientation(6, false).subarray(0, 20)), 1);
assertEquals(turn(PNG), 1);
});
Deno.test("scalableImage: an animated WebP and what isn't an image are served as they are", () => {
const webp = (flags: number) =>
new Uint8Array([
...new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8X"),
...[10, 0, 0, 0],
flags,
]);
assertEquals(scalableImage(webp(0x02)), null);
// A reader without WebP gets a PNG of what may be transparent, a JPEG of
// the rest.
const still = { orientation: 1, animated: false };
assertEquals(scalableImage(webp(0x10)), { ...still, compat: "png" });
assertEquals(
scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
{ ...still, compat: "png" },
);
assertEquals(scalableImage(PNG), { ...still, compat: "png" });
assertEquals(scalableImage(jpegWithOrientation(1, true)), {
...still,
compat: "jpg",
});
assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), {
...still,
compat: "jpg",
});
// Only a GIF's frames are an animation to keep.
assertEquals(scalableImage(new TextEncoder().encode("GIF89a")), {
orientation: 1,
animated: true,
compat: "png",
});
assertEquals(scalableImage(new TextEncoder().encode("<svg/>")), null);
assertEquals(scalableImage(new Uint8Array()), null);
});

View File

@@ -32,13 +32,14 @@ Deno.test("readTokens: a forced scheme's block overrides the theme's", () => {
Deno.test("readTokens: var() follows tokens, color-mix() is computed", () => {
const melange = readTokens("melange", "light");
// The spice starts with var(--color-accent) and drops its stops' positions.
// The spice holds var()s, the accent among them, and drops its stops'
// positions.
assertEquals(melange.stops("--mel-spice"), [
"#a06d00",
"#bc5c00",
"#c77b8b",
"#be79bb",
"#6e9b72",
"#ea6a45",
"#e2568f",
"#8262d8",
"#3a8fd4",
"#27a583",
]);
// 18% of the muted text over the card, as a badge shows on the site.
const smooth = readTokens("smooth", "light");

View File

@@ -0,0 +1,90 @@
import { BASE_URL } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import { Application } from "@oak/oak";
import { brotliDecompressSync, gunzipSync } from "node:zlib";
import { compressMiddleware } from "../../middleware/compress.ts";
const BIG = { success: true, data: { items: Array(200).fill("dump title") } };
function makeApp(): Application {
const app = new Application();
app.use(compressMiddleware);
app.use((ctx) => {
const path = ctx.request.url.pathname;
if (path === "/big") ctx.response.body = BIG;
if (path === "/small") ctx.response.body = { success: true };
if (path === "/html") {
ctx.response.type = "text/html";
ctx.response.body = `<!doctype html>${"<p>gerbeur</p>".repeat(200)}`;
}
if (path === "/bytes") ctx.response.body = new Uint8Array(4096);
if (path === "/encoded") {
ctx.response.headers.set("Content-Encoding", "br");
ctx.response.type = "text/css";
ctx.response.body = "x".repeat(4096);
}
});
return app;
}
async function get(path: string, acceptEncoding?: string) {
const res = await makeApp().handle(
new Request(`${BASE_URL}${path}`, {
headers: acceptEncoding ? { "Accept-Encoding": acceptEncoding } : {},
}),
);
if (!res) throw new Error("no response");
return { res, bytes: new Uint8Array(await res.arrayBuffer()) };
}
const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
Deno.test("compressMiddleware: JSON goes out in brotli, or gzip for a client without it", async () => {
const br = await get("/big", "gzip, deflate, br");
assertEquals(br.res.headers.get("Content-Encoding"), "br");
assertEquals(
br.res.headers.get("Content-Type"),
"application/json; charset=UTF-8",
);
assertEquals(br.res.headers.get("Vary"), "Accept-Encoding");
assertEquals(JSON.parse(text(brotliDecompressSync(br.bytes))), BIG);
const gzip = await get("/big", "gzip");
assertEquals(gzip.res.headers.get("Content-Encoding"), "gzip");
assertEquals(JSON.parse(text(gunzipSync(gzip.bytes))), BIG);
const refused = await get("/big", "br;q=0, gzip;q=0.5");
assertEquals(refused.res.headers.get("Content-Encoding"), "gzip");
});
Deno.test("compressMiddleware: a client that accepts no coding gets the JSON as it is", async () => {
for (const accept of [undefined, "identity", "br;q=0"]) {
const { res, bytes } = await get("/big", accept);
assertEquals(res.headers.get("Content-Encoding"), null);
// A cache still has to tell this answer from the compressed one.
assertEquals(res.headers.get("Vary"), "Accept-Encoding");
assertEquals(JSON.parse(text(bytes)), BIG);
}
});
Deno.test("compressMiddleware: the HTML page is compressed too", async () => {
const { res, bytes } = await get("/html", "br");
assertEquals(res.headers.get("Content-Encoding"), "br");
assertEquals(res.headers.get("Content-Type"), "text/html; charset=UTF-8");
assertEquals(text(brotliDecompressSync(bytes)).startsWith("<!doctype"), true);
});
Deno.test("compressMiddleware: short answers, bytes and encoded files are left alone", async () => {
const small = await get("/small", "br");
assertEquals(small.res.headers.get("Content-Encoding"), null);
assertEquals(small.res.headers.get("Vary"), null);
assertEquals(JSON.parse(text(small.bytes)), { success: true });
const bytes = await get("/bytes", "br");
assertEquals(bytes.res.headers.get("Content-Encoding"), null);
assertEquals(bytes.bytes.length, 4096);
const encoded = await get("/encoded", "br");
assertEquals(encoded.res.headers.get("Content-Encoding"), "br");
assertEquals(text(encoded.bytes), "x".repeat(4096));
});

View File

@@ -1,4 +1,14 @@
import { createUser, formWithFile, get, PNG, put, test } from "../helpers.ts";
import {
createUser,
formWithFile,
get,
hasFfmpeg,
makeImage,
PNG,
put,
test,
webpWidth,
} from "../helpers.ts";
import type { SelfUser } from "../../../shared/user.ts";
import { assertEquals, assertFalse } from "@std/assert";
@@ -27,3 +37,53 @@ test("avatars: upload, serve, and 404 when there is none", async () => {
400,
);
});
test("avatars: a still holding a second image is scaled to its first alone", async () => {
if (!await hasFfmpeg()) return;
const user = await createUser();
// As a phone's photo carries a gain map after it, which ffmpeg reads as a
// second frame.
const photo = await makeImage(600, 400, "red");
const extra = await makeImage(150, 100, "white");
const both = new Uint8Array(photo.length + extra.length);
both.set(photo);
both.set(extra, photo.length);
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(both) });
const small = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
assertFalse(new TextDecoder().decode(small.bytes).includes("ANMF"));
});
test("avatars: `?w=` answers a scaled copy, made again from a new avatar", async () => {
if (!await hasFfmpeg()) return;
const user = await createUser();
const red = await makeImage(600, 400, "red");
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(red) });
const small = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(small.status, 200);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(small.headers.get("cache-control"), "public, max-age=3600");
assertEquals(webpWidth(small.bytes), 128);
// Never wider than the upload itself.
assertEquals(
webpWidth((await get(`/api/users/${user.id}/avatar?w=960`)).bytes),
600,
);
// Without a width, or with one that isn't a number, the upload goes out.
assertEquals((await get(`/api/users/${user.id}/avatar`)).bytes, red);
assertEquals((await get(`/api/users/${user.id}/avatar?w=big`)).bytes, red);
await put("/api/me/avatar", {
auth: user.auth,
form: formWithFile(await makeImage(600, 400, "blue")),
});
const again = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(webpWidth(again.bytes), 128);
assertFalse(
again.bytes.length === small.bytes.length &&
again.bytes.every((b, i) => b === small.bytes[i]),
);
});

View File

@@ -1,6 +1,9 @@
import { createUser, db, get, insertDump, test } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import type { CommunityStats } from "../../../shared/community.ts";
import type {
CommunityFeed,
CommunityStats,
} from "../../../shared/community.ts";
import { refreshCommunityStats } from "../../services/community-service.ts";
function vote(dumpId: string, n: number) {
@@ -33,3 +36,25 @@ test("GET /api/community: signed in only, for a week or a month", async () => {
});
assertEquals(month.data.totals.dumps, 1);
});
test("GET /api/community/feed: signed in only, the latest events and the week's figures", async () => {
const ann = await createUser();
insertDump({
userId: ann.id,
title: "Hit",
createdAt: new Date(Date.now() - 1000).toISOString(),
});
refreshCommunityStats();
assertEquals((await get("/api/community/feed")).status, 401);
const feed = await get<CommunityFeed>("/api/community/feed", {
auth: ann.auth,
});
assertEquals(feed.status, 200);
assertEquals(feed.data.events.map((e) => e.type), [
"user_joined",
"dump_posted",
]);
assertEquals(feed.data.week.totals.dumps, 1);
assertEquals(feed.data.week.top, undefined);
});

View File

@@ -5,6 +5,7 @@ import {
del,
formWithFile,
get,
hasFfmpeg,
html,
insertCategory,
insertComment,
@@ -767,16 +768,27 @@ test("custom thumbnails: set, served, removed", async () => {
});
assertEquals(set.status, 200);
assertEquals(set.data.thumbnailMime, "image/png");
// A scaled copy by default, as for an upload's own thumbnail; the image
// itself where no copy can be made.
const thumb = await get(`/api/dumps/${id}/thumbnail`);
assertEquals(thumb.status, 200);
assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG);
if (await hasFfmpeg()) {
assertEquals(thumb.headers.get("content-type"), "image/webp");
assertEquals(
readFile(`scaled/thumbnails/${id}-custom.w960.webp`) !== null,
true,
);
} else {
assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG);
}
const removed = await del<Dump>(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth,
});
assertEquals(removed.data.thumbnailMime, undefined);
assertEquals(readFile(`thumbnails/${id}-custom`), null);
assertEquals(readFile(`scaled/thumbnails/${id}-custom.w960.webp`), null);
// A URL dump without its own thumbnail has none to serve.
assertEquals((await get(`/api/dumps/${id}/thumbnail`)).status, 404);
});

View File

@@ -16,10 +16,12 @@ import {
TEST_DIR,
} from "../helpers.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import type {
Playlist,
PlaylistMembership,
PlaylistWithDumps,
import {
comparePlaylists,
type Playlist,
PLAYLIST_SORTS,
type PlaylistMembership,
type PlaylistWithDumps,
} from "../../../shared/playlist.ts";
import { assert, assertEquals, assertMatch } from "@std/assert";
@@ -146,6 +148,72 @@ test("a public playlist hides its private dumps from everyone but their owner",
);
});
test("a playlist's dumpCount is of the dumps the viewer may see", async () => {
const owner = await createUser();
const author = await createUser();
const pl = insertPlaylist({ userId: owner.id, title: "Counted banana" });
const pub = insertDump({ userId: owner.id });
const mine = insertDump({ userId: owner.id, isPrivate: true });
// Someone else's, gone private after it was added: its owner no longer
// sees it there, and neither does its poster.
const theirs = insertDump({ userId: author.id, isPrivate: true });
for (const [i, d] of [pub, mine, theirs].entries()) {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(pl.id, d.id, i, new Date().toISOString());
}
await put(`/api/me/follow/playlists/${pl.id}`, { auth: author.auth });
const lists = [
"/api/playlists",
`/api/users/@${owner.username}/playlists`,
`/api/users/@${author.username}/follow/playlists`,
];
for (
const [auth, expected] of [
[owner.auth, 2],
[author.auth, 1],
[undefined, 1],
] as const
) {
for (const url of lists) {
const { items } = (await get<PaginatedData<Playlist>>(url, { auth }))
.data;
assertEquals(items.map((p) => p.dumpCount), [expected], url);
}
assertEquals(
(await get<{ playlists: Playlist[] }>("/api/search?q=banana", { auth }))
.data.playlists.map((p) => p.dumpCount),
[expected],
);
assertEquals(
(await get<Playlist[]>(`/api/dumps/${pub.id}/playlists`, { auth })).data
.map((p) => p.dumpCount),
[expected],
);
const page = await get<PlaylistWithDumps>(`/api/playlists/${pl.id}`, {
auth,
});
assertEquals(page.data.dumpCount, expected);
assertEquals(ids(page).length, expected);
}
assertEquals(
(await get<PlaylistMembership[]>(`/api/me/playlists?contains=${pub.id}`, {
auth: owner.auth,
})).data.map((m) => [m.hasDump, m.playlist.dumpCount]),
[[true, 2]],
);
// An edit answers with the editor's count.
assertEquals(
(await put<Playlist>(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { title: "Counted" },
})).data.dumpCount,
2,
);
});
test("reorder: must name exactly the current members", async () => {
const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id });
@@ -599,7 +667,7 @@ test("playlist categories: filed on creation, replaced on update, dump categorie
assertEquals(cleared.data.categoryIds, []);
});
test("GET /api/playlists: every public playlist, newest first, by category", async () => {
test("GET /api/playlists: every public playlist, by category", async () => {
const owner = await createUser();
const other = await createUser();
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
@@ -663,3 +731,110 @@ test("GET /api/users/:key/playlists?category=: their playlists in one category",
[],
);
});
test("?sort=: by the last dump added, by creation or by dumps, as the viewer sees them", async () => {
const owner = await createUser();
const made = (title: string, createdAt: string) => {
const { id } = insertPlaylist({ userId: owner.id, title });
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
.run(createdAt, id);
return id;
};
const add = (playlistId: string, addedAt: string, isPrivate = false) =>
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, 0, ?);`,
).run(playlistId, insertDump({ userId: owner.id, isPrivate }).id, addedAt);
const a = made("A", "2026-01-01T00:00:00.000Z");
const b = made("B", "2026-02-01T00:00:00.000Z");
const c = made("C", "2026-03-01T00:00:00.000Z");
add(a, "2026-04-01T00:00:00.000Z");
add(b, "2026-02-10T00:00:00.000Z");
add(b, "2026-02-11T00:00:00.000Z");
// Only the owner sees it: to anyone else, C is empty and as it was made.
add(c, "2026-05-01T00:00:00.000Z", true);
type Auth = typeof owner.auth;
const list = async (query: string, auth?: Auth) =>
(await get<PaginatedData<Playlist>>(query, { auth })).data.items;
const titles = async (query: string, auth?: Auth) =>
(await list(query, auth)).map((p) => p.title);
for (
const url of ["/api/playlists", `/api/users/${owner.id}/playlists`]
) {
assertEquals(await titles(`${url}?sort=updated`), ["A", "C", "B"], url);
assertEquals(await titles(`${url}?sort=new`), ["C", "B", "A"], url);
assertEquals(await titles(`${url}?sort=dumps`), ["B", "A", "C"], url);
assertEquals(
await titles(`${url}?sort=updated`, owner.auth),
["C", "A", "B"],
url,
);
// A and C hold one dump each to their owner: the newer goes first.
assertEquals(
await titles(`${url}?sort=dumps`, owner.auth),
["B", "C", "A"],
url,
);
// The first order is the default, which an unknown one falls back to.
assertEquals(await titles(url), ["A", "C", "B"], url);
assertEquals(await titles(`${url}?sort=nope`), ["A", "C", "B"], url);
assertEquals(await titles(`${url}?sort=new&limit=1&page=2`), ["B"], url);
}
const dates = (await list("/api/playlists?sort=new")).map((p) =>
p.lastDumpAt
);
assertEquals(dates, [
undefined,
"2026-02-11T00:00:00.000Z",
"2026-04-01T00:00:00.000Z",
] as unknown[]);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${c}`, { auth: owner.auth }))
.data.lastDumpAt as unknown,
"2026-05-01T00:00:00.000Z",
);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${c}`)).data.lastDumpAt,
undefined,
);
});
test("comparePlaylists orders playlists as the API's ?sort= pages them", async () => {
const owner = await createUser();
// Ties of every kind: dates and counts shared by several playlists.
for (let i = 0; i < 8; i++) {
const { id } = insertPlaylist({ userId: owner.id, title: `P${i}` });
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
.run(`2026-01-0${1 + i}T00:00:00.000Z`, id);
for (let n = 0; n < i % 3; n++) {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(
id,
insertDump({ userId: owner.id }).id,
n,
`2026-02-0${1 + ((i * 5 + n) % 9)}T00:00:00.000Z`,
);
}
}
for (const sort of PLAYLIST_SORTS) {
const { items } = (await get<PaginatedData<Playlist>>(
`/api/playlists?sort=${sort}`,
)).data;
// The dates come as strings over the wire.
const playlists = items.map((p) => ({
...p,
createdAt: new Date(p.createdAt),
lastDumpAt: p.lastDumpAt && new Date(p.lastDumpAt),
}));
assertEquals(
playlists.toSorted(comparePlaylists(sort)).map((p) => p.title),
items.map((p) => p.title),
sort,
);
}
});

View File

@@ -1,4 +1,12 @@
import { get, html, PNG, stubNetwork } from "../helpers.ts";
import {
get,
hasFfmpeg,
html,
makeImage,
PNG,
stubNetwork,
webpWidth,
} from "../helpers.ts";
import { assertEquals, assertFalse } from "@std/assert";
import type { Tralbum, YouTubePlaylist } from "../../../shared/player.ts";
@@ -29,6 +37,25 @@ Deno.test("GET /api/remote/image: rasters only, fetched once for everyone", asyn
assertEquals(svg.status, 400);
});
Deno.test("GET /api/remote/image: `?w=` scales the image, fetched once for every width", async () => {
if (!await hasFfmpeg()) return;
const wide = await makeImage(800, 200);
using net = stubNetwork(() =>
new Response(wide, { headers: { "content-type": "image/png" } })
);
const path = `/api/remote/image?url=${
encodeURIComponent("https://cdn.example.com/wide.png")
}`;
const small = await get(`${path}&w=128`);
assertEquals(small.status, 200);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
assertEquals(webpWidth((await get(`${path}&w=320`)).bytes), 320);
assertEquals((await get(`${path}&w=128`)).bytes, small.bytes);
assertEquals((await get(path)).bytes, wide);
assertEquals(net.calls.length, 1);
});
Deno.test("GET /api/remote/image: an error answer is never served nor cached", async () => {
let failing = true;
using net = stubNetwork(() =>

View File

@@ -1,34 +1,24 @@
import {
createUser,
db,
del,
get,
hasFfmpeg,
insertDump,
makeImage,
PNG,
test,
TEST_DIR,
webpWidth,
} from "../helpers.ts";
import { assertEquals } from "@std/assert";
const BYTES = new TextEncoder().encode("0123456789");
function createdAtOf(id: string): string {
return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as {
created_at: string;
}).created_at;
}
async function hasFfmpeg(): Promise<boolean> {
try {
return (await new Deno.Command("ffmpeg", {
args: ["-version"],
stdout: "null",
stderr: "null",
}).output()).success;
} catch {
return false;
}
}
function readDir(path: string): string[] {
try {
return [...Deno.readDirSync(path)].map((e) => e.name);
@@ -81,54 +71,87 @@ test("GET /api/dumps/:id/thumbnail: serves a cached video still, with privacy",
assertEquals((await get(`/api/dumps/nope/thumbnail`)).status, 400);
});
test("GET /api/dumps/:id/thumbnail: an image dump gets a preview, a GIF itself", async () => {
test("GET /api/dumps/:id/thumbnail: an image dump gets a scaled copy, or itself when none can be made", async () => {
const owner = await createUser();
const scaled = (id: string) =>
readDir(`${TEST_DIR}/uploads/scaled/dumps`).filter((n) => n.startsWith(id))
.sort();
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
const gif = new TextEncoder().encode("GIF89a-not-really");
const animated = insertDump({
const broken = insertDump({
userId: owner.id,
fileName: "a.gif",
fileMime: "image/gif",
fileData: gif,
});
const res = await get(`/api/dumps/${animated.id}/thumbnail`);
assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type"), "image/gif");
assertEquals(res.bytes, gif);
const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
assertEquals(fallback.status, 200);
assertEquals(fallback.headers.get("content-type"), "image/gif");
assertEquals(fallback.bytes, gif);
assertEquals(scaled(broken.id), []);
// A real PNG is scaled into a preview, kept beside the video stills.
if (!await hasFfmpeg()) {
const photo = insertDump({
userId: owner.id,
fileName: "p.png",
fileMime: "image/png",
fileData: PNG,
});
const itself = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(itself.headers.get("content-type"), "image/png");
assertEquals(itself.bytes, PNG);
return;
}
// A real image is scaled to the widest width without `w`, and to the width
// asked for with it, each copy kept under the upload's own path.
const photo = insertDump({
userId: owner.id,
fileName: "p.png",
fileMime: "image/png",
fileData: PNG,
fileData: await makeImage(1400, 300),
});
const preview = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(preview.status, 200);
assertEquals(preview.headers.get("content-type"), "image/png");
if (await hasFfmpeg()) {
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(photo.id)
),
[`${photo.id}.preview.png`],
);
}
const widest = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(widest.status, 200);
assertEquals(widest.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(widest.bytes), 960);
const small = await get(`/api/dumps/${photo.id}/thumbnail?w=128`);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
// A width between two of the list's takes the one above.
const between = await get(`/api/dumps/${photo.id}/thumbnail?w=200`);
assertEquals(webpWidth(between.bytes), 320);
assertEquals(scaled(photo.id), [
`${photo.id}.w128.webp`,
`${photo.id}.w320.webp`,
`${photo.id}.w960.webp`,
]);
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
const broken = insertDump({
// A reader without WebP, as a mail client, asks for a PNG or a JPEG.
const compat = await get(`/api/dumps/${photo.id}/thumbnail?w=640&f=compat`);
assertEquals(compat.status, 200);
assertEquals(compat.headers.get("content-type"), "image/png");
// Its header states the width, on four bytes from the sixteenth.
assertEquals(new DataView(compat.bytes.buffer).getUint32(16), 640);
assertEquals(scaled(photo.id).includes(`${photo.id}.w640.png`), true);
// An animation keeps its frames.
const animated = insertDump({
userId: owner.id,
fileName: "b.png",
fileMime: "image/png",
fileData: BYTES,
fileName: "anim.gif",
fileMime: "image/gif",
fileData: await makeImage(400, 300, "blue", "gif"),
});
const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
assertEquals(fallback.status, 200);
assertEquals(fallback.headers.get("content-type"), "image/png");
assertEquals(fallback.bytes, BYTES);
const moving = await get(`/api/dumps/${animated.id}/thumbnail?w=128`);
assertEquals(moving.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(moving.bytes), 128);
assertEquals(new TextDecoder().decode(moving.bytes).includes("ANMF"), true);
// The copies go with the dump.
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(broken.id)
),
[],
(await del(`/api/dumps/${photo.id}`, { auth: owner.auth })).status,
200,
);
assertEquals(scaled(photo.id), []);
});

View File

@@ -654,6 +654,78 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
assertEquals(x.all("playlist_dumps_updated"), []);
});
wsTest(
"dump_related_changed reaches those who may see both ends of a link",
async (connect) => {
const owner = await createUser();
const other = await createUser();
const src = insertDump({ userId: owner.id, title: "src" });
const target = insertDump({ userId: other.id, title: "target" });
const secret = insertDump({
userId: other.id,
title: "secret",
isPrivate: true,
});
const o = await connect(owner);
const x = await connect(other);
const anon = await connect();
// A comment linking a public dump and a private one: the latter's poster
// alone hears of it.
const links =
`http://gerbeur.test${target.path} http://gerbeur.test${secret.path}`;
const comment = await post<Comment>(`/api/dumps/${src.id}/comments`, {
auth: owner.auth,
json: { body: links },
});
for (const c of [o, anon]) {
assertEquals((await c.next("dump_related_changed")).dumpIds, [
src.id,
target.id,
]);
}
const both = (await x.next("dump_related_changed")).dumpIds;
assertEquals(both[0], src.id);
assertEquals(new Set(both.slice(1)), new Set([target.id, secret.id]));
// An edit that changes no link says nothing; a deletion drops them.
await patch(`/api/comments/${comment.data.id}`, {
auth: owner.auth,
json: { body: `${links} again` },
});
await o.next("comment_updated");
assertEquals(o.all("dump_related_changed").length, 1);
await del(`/api/comments/${comment.data.id}`, { auth: owner.auth });
assertEquals((await anon.next("dump_related_changed")).dumpIds, [
src.id,
target.id,
]);
await o.next("dump_related_changed");
await x.next("dump_related_changed");
// A private dump's links are its poster's alone, until it turns public.
const hidden = insertDump({ userId: owner.id, isPrivate: true });
await put(`/api/dumps/${hidden.id}`, {
auth: owner.auth,
json: { comment: `http://gerbeur.test${target.path}` },
});
assertEquals((await o.next("dump_related_changed")).dumpIds, [
hidden.id,
target.id,
]);
await x.sync();
assertEquals(x.all("dump_related_changed").length, 2);
await put(`/api/dumps/${hidden.id}`, {
auth: owner.auth,
json: { isPrivate: false },
});
assertEquals((await x.next("dump_related_changed")).dumpIds, [
hidden.id,
target.id,
]);
},
);
wsTest("a password change closes the user's sockets", async (connect) => {
const user = await createUser();
const c = await connect(user);
@@ -672,6 +744,47 @@ wsTest("a password change closes the user's sockets", async (connect) => {
assertFalse(res.data.token === user.token);
});
wsTest(
"playlist_dumps_updated dates the latest dump each viewer can see",
async (connect) => {
const owner = await createUser();
const pl = insertPlaylist({ userId: owner.id });
const o = await connect(owner);
const anon = await connect();
const add = async (isPrivate: boolean) => {
const dump = insertDump({ userId: owner.id, isPrivate });
await post(`/api/playlists/${pl.id}/dumps/${dump.id}`, {
auth: owner.auth,
});
return [
(await o.next("playlist_dumps_updated")).lastDumpAt,
(await anon.next("playlist_dumps_updated")).lastDumpAt,
];
};
const [first, firstToAnon] = await add(false);
assert(first);
assertEquals(firstToAnon, first);
// The owner's private dump moves the date for them alone.
const [second, secondToAnon] = await add(true);
assert(second && second >= first);
assertEquals(secondToAnon, first);
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { title: "Renamed" },
});
assertEquals(
(await o.next("playlist_updated")).playlist.lastDumpAt,
second,
);
assertEquals(
(await anon.next("playlist_updated")).playlist.lastDumpAt,
first,
);
},
);
wsTest(
"playlist_dumps_updated lists only what each viewer can see",
async (connect) => {
@@ -709,8 +822,32 @@ wsTest(
for (const c of [a, x, anon]) {
const msg = await c.next("playlist_dumps_updated");
assertEquals(msg.dumpIds, [added.id, pub.id]);
// The count is the playlist's, as its dumpCount gives it to everyone.
assertEquals(msg.dumpCount, 4);
}
// A change to its details carries the count of what each may see, too.
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { title: "Renamed" },
});
assertEquals((await o.next("playlist_updated")).playlist.dumpCount, 3);
for (const c of [a, x, anon]) {
assertEquals((await c.next("playlist_updated")).playlist.dumpCount, 2);
}
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { isPublic: false },
});
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { isPublic: true },
});
assertEquals(
(await o.next("playlist_updated", (m) => m.playlist.isPublic)).playlist
.dumpCount,
3,
);
for (const c of [a, x, anon]) {
assertEquals((await c.next("playlist_created")).playlist.dumpCount, 2);
}
},
);
@@ -817,7 +954,6 @@ wsTest(
});
const hidden = await x.next("playlist_dumps_updated");
assertEquals(hidden.dumpIds, [keep.id, gone.id]);
assertEquals(hidden.dumpCount, 3);
assertEquals(
(await o.next("playlist_dumps_updated")).dumpIds,
[keep.id, flip.id, gone.id],
@@ -835,7 +971,7 @@ wsTest(
await del(`/api/dumps/${gone.id}`, { auth: owner.auth });
const after = await x.next(
"playlist_dumps_updated",
(m) => m.dumpCount === 2,
(m) => m.dumpIds.length === 2,
);
assertEquals(after.dumpIds, [keep.id, flip.id]);

View File

@@ -9,7 +9,9 @@ import {
import { assertEquals } from "@std/assert";
import {
communityStatsOf,
getCommunityFeed,
getCommunityStats,
listCommunityEvents,
refreshCommunityStats,
} from "../../services/community-service.ts";
@@ -43,9 +45,25 @@ test("communityStatsOf: the period's top public dumps, members, public playlists
insertPlaylist({ userId: ann.id, title: "Small" });
const full = insertPlaylist({ userId: bob.id, title: "Full" });
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
// Bob's private dump isn't counted: every reader gets the same figures.
const secret = insertDump({
userId: bob.id,
title: "Secret",
isPrivate: true,
});
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?), (?, ?, 1, ?);`,
).run(full.id, hit.id, ago(0), full.id, tie.id, ago(0));
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?), (?, ?, 1, ?), (?, ?, 2, ?);`,
).run(
full.id,
hit.id,
ago(0),
full.id,
tie.id,
ago(0),
full.id,
secret.id,
ago(0),
);
const stats = communityStatsOf(...week());
assertEquals(stats.top.map((d) => d.title), ["Hit", "Newer", "Tie"]);
@@ -86,3 +104,109 @@ test("getCommunityStats: serves the last refresh, of the week or the month", asy
"Later",
]);
});
function castVote(dumpId: string, userId: string, at: string) {
db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(dumpId, userId, at);
}
test("listCommunityEvents: what was done in public, newest first, a dump's latest comment and upvote alone", async () => {
const ann = await createUser({ createdAt: ago(9 * DAY) });
const bob = await createUser({ invitedBy: ann.id, createdAt: ago(8 * DAY) });
const hit = insertDump({
userId: ann.id,
title: "Hit",
createdAt: ago(7 * DAY),
});
const secret = insertDump({
userId: ann.id,
title: "Secret",
isPrivate: true,
});
insertComment({ dumpId: hit.id, userId: ann.id, createdAt: ago(6 * DAY) });
const last = insertComment({
dumpId: hit.id,
userId: bob.id,
createdAt: ago(5 * DAY),
});
const gone = insertComment({ dumpId: hit.id, userId: ann.id });
db.prepare(`UPDATE comments SET deleted = 1 WHERE id = ?;`).run(gone);
insertComment({ dumpId: secret.id, userId: ann.id });
castVote(hit.id, bob.id, ago(4 * DAY));
castVote(hit.id, ann.id, ago(3 * DAY));
castVote(secret.id, ann.id, ago(0));
const mix = insertPlaylist({ userId: bob.id, title: "Mix" });
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
const events = listCommunityEvents();
assertEquals(
events.map((e) => [e.type, e.username]),
[
["playlist_created", bob.username],
["dump_upvoted", ann.username],
["dump_commented", bob.username],
["dump_posted", ann.username],
["user_joined", bob.username],
["user_joined", ann.username],
],
);
const [playlist, upvote, comment, , joined] = events;
assertEquals(playlist.type === "playlist_created" && playlist.playlist, {
title: "Mix",
slug: mix.slug,
ownerUsername: bob.username,
});
assertEquals(upvote.type === "dump_upvoted" && upvote.dump, {
title: "Hit",
slug: hit.slug,
ownerUsername: ann.username,
});
assertEquals(upvote.id, `dump_upvoted:${hit.id}`);
assertEquals(comment.type === "dump_commented" && comment.commentId, last);
assertEquals(
joined.type === "user_joined" && joined.invitedByUsername,
ann.username,
);
});
test("listCommunityEvents: keeps the 12 latest", async () => {
const ann = await createUser({ createdAt: ago(DAY) });
for (let i = 0; i < 14; i++) {
insertDump({
userId: ann.id,
title: `Dump ${i}`,
createdAt: ago(i * 1000),
});
}
const events = listCommunityEvents();
assertEquals(events.length, 12);
assertEquals(
events.map((e) => e.type === "dump_posted" && e.dump.title),
Array.from({ length: 12 }, (_, i) => `Dump ${i}`),
);
});
test("getCommunityFeed: the events as they are now, the week's figures as of the last refresh", async () => {
const ann = await createUser({ createdAt: ago(20 * DAY) });
const hit = insertDump({
userId: ann.id,
title: "Hit",
createdAt: ago(1000),
});
vote(hit.id, 3);
refreshCommunityStats();
insertDump({ userId: ann.id, title: "Later" });
const feed = getCommunityFeed();
assertEquals(
feed.events.map((e) => e.type === "dump_posted" && e.dump.title),
["Later", "Hit", false],
);
assertEquals(feed.week.totals.dumps, 1);
assertEquals(feed.week.top, {
title: "Hit",
slug: hit.slug,
ownerUsername: ann.username,
});
});

94
deno.lock generated
View File

@@ -29,34 +29,38 @@
"jsr:@std/path@^1.1.6": "1.1.6",
"jsr:@std/testing@^1.0.21": "1.0.21",
"jsr:@tajpouria/cors@^1.2.1": "1.2.1",
"npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0",
"npm:@fontsource-variable/fraunces@5.3.0": "5.3.0",
"npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.11.0_jiti@2.7.0",
"npm:@fontsource-variable/bricolage-grotesque@5.3.0": "5.3.0",
"npm:@fontsource-variable/jetbrains-mono@5.3.0": "5.3.0",
"npm:@fontsource-variable/libre-franklin@5.3.0": "5.3.0",
"npm:@fontsource-variable/newsreader@5.3.0": "5.3.0",
"npm:@fontsource-variable/saira@5.3.0": "5.3.0",
"npm:@fontsource-variable/space-grotesk@5.3.0": "5.3.0",
"npm:@fontsource/comic-neue@5.3.0": "5.3.0",
"npm:@fontsource/instrument-serif@5.3.0": "5.3.0",
"npm:@fontsource/manufacturing-consent@5.3.0": "5.3.0",
"npm:@lingui/cli@*": "6.8.0",
"npm:@lingui/cli@6.8.0": "6.8.0",
"npm:@lingui/conf@6.8.0": "6.8.0",
"npm:@lingui/core@6.8.0": "6.8.0_@babel+core@7.29.7",
"npm:@lingui/format-po@6.8.0": "6.8.0",
"npm:@lingui/react@6.8.0": "6.8.0_react@19.3.0_@babel+core@7.29.7",
"npm:@lingui/swc-plugin@6.7.0": "6.7.0_@lingui+core@6.8.0__@babel+core@7.29.7_@babel+core@7.29.7",
"npm:@lingui/vite-plugin@6.8.0": "6.8.0_@babel+core@7.29.7_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0",
"npm:@lingui/swc-plugin@6.7.0": "6.7.0_@lingui+core@6.8.0_@babel+core@7.29.7",
"npm:@lingui/vite-plugin@6.8.0": "6.8.0_@babel+core@7.29.7_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:@resvg/resvg-wasm@2.6.2": "2.6.2",
"npm:@resvg/resvg-wasm@^2.6.2": "2.6.2",
"npm:@testing-library/dom@^10.4.2": "10.4.2",
"npm:@testing-library/jest-dom@^7.0.1": "7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.6.3__@vitest+coverage-v8@5.0.2__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0_@vitest+coverage-v8@5.0.2__vitest@5.0.2__@types+node@26.6.3__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0",
"npm:@testing-library/react@^16.3.3": "16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0__@types+react@19.3.0_react@19.3.0_react-dom@19.3.0__react@19.3.0",
"npm:@testing-library/jest-dom@^7.0.1": "7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2_@vitest+coverage-v8@5.0.2",
"npm:@testing-library/react@^16.3.3": "16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0",
"npm:@testing-library/user-event@^14.6.7": "14.6.7_@testing-library+dom@10.4.2",
"npm:@types/node@^26.6.3": "26.6.3",
"npm:@types/react-dom@^19.3.0": "19.3.0_@types+react@19.3.0",
"npm:@types/react@^19.3.0": "19.3.0",
"npm:@vitejs/plugin-react-swc@^4.3.3": "4.3.3_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0",
"npm:@vitest/coverage-v8@^5.0.2": "5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0",
"npm:@vitejs/plugin-react-swc@^4.3.3": "4.3.3_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:@vitest/coverage-v8@^5.0.2": "5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1",
"npm:emojibase-data@17.0.0": "17.0.0_emojibase@17.0.0",
"npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0",
"npm:eslint-plugin-react-refresh@~0.5.7": "0.5.7_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0",
"npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.11.0_jiti@2.7.0",
"npm:eslint-plugin-react-refresh@~0.5.7": "0.5.7_eslint@10.11.0_jiti@2.7.0",
"npm:eslint@^10.11.0": "10.11.0_jiti@2.7.0",
"npm:frimousse@0.4": "0.4.0_react@19.3.0_typescript@6.0.3",
"npm:globals@^17.12.0": "17.12.0",
@@ -68,15 +72,15 @@
"npm:react-dom@^19.3.0": "19.3.0_react@19.3.0",
"npm:react-hook-form@^7.89.0": "7.89.0_@types+react@19.3.0_react@19.3.0",
"npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.3.0_react@19.3.0",
"npm:react-router@^8.4.0": "8.4.0_react@19.3.0_react-dom@19.3.0__react@19.3.0",
"npm:react-router@^8.4.0": "8.4.0_react@19.3.0_react-dom@19.3.0",
"npm:react@^19.3.0": "19.3.0",
"npm:remark-gfm@^4.0.1": "4.0.1",
"npm:typescript-eslint@^8.70.1": "8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0",
"npm:typescript-eslint@^8.70.1": "8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0",
"npm:typescript@~6.0.3": "6.0.3",
"npm:vite@*": "8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:vite@^8.3.1": "8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:vitest@*": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0",
"npm:vitest@^5.0.2": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0"
"npm:vitest@*": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1",
"npm:vitest@^5.0.2": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1"
},
"jsr": {
"@db/sqlite@0.13.0": {
@@ -382,14 +386,14 @@
"@csstools/color-helpers@6.1.2": {
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="
},
"@csstools/css-calc@3.4.1_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": {
"@csstools/css-calc@3.4.1_@csstools+css-parser-algorithms@4.0.1_@csstools+css-tokenizer@4.0.2": {
"integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
"dependencies": [
"@csstools/css-parser-algorithms",
"@csstools/css-tokenizer"
]
},
"@csstools/css-color-parser@4.2.4_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": {
"@csstools/css-color-parser@4.2.4_@csstools+css-parser-algorithms@4.0.1_@csstools+css-tokenizer@4.0.2": {
"integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
"dependencies": [
"@csstools/color-helpers",
@@ -416,7 +420,7 @@
"@csstools/css-tokenizer@4.0.2": {
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw=="
},
"@eslint-community/eslint-utils@4.9.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": {
"@eslint-community/eslint-utils@4.9.1_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dependencies": [
"eslint",
@@ -446,7 +450,7 @@
"@types/json-schema"
]
},
"@eslint/js@10.0.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": {
"@eslint/js@10.0.1_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dependencies": [
"eslint"
@@ -468,21 +472,33 @@
"@exodus/bytes@1.16.0": {
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="
},
"@fontsource-variable/fraunces@5.3.0": {
"integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA=="
"@fontsource-variable/bricolage-grotesque@5.3.0": {
"integrity": "sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA=="
},
"@fontsource-variable/jetbrains-mono@5.3.0": {
"integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="
},
"@fontsource-variable/libre-franklin@5.3.0": {
"integrity": "sha512-PEQemCKVRtkLZJNpfvR4wG7x87c8iG05XJuEaX59ogMcAQpdtCZz/NXZUrXLJyvyx3/HD70W4LUppWLBdEdU2g=="
},
"@fontsource-variable/newsreader@5.3.0": {
"integrity": "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw=="
},
"@fontsource-variable/saira@5.3.0": {
"integrity": "sha512-C1rm7n1OuEx4Se/SAB/5EeUuCviGFFnYTr5vxuQQm2YIOfgbQWx/P6qqhdXlVCBRm/1UzNWCtDK5jNbVmO6W2w=="
},
"@fontsource-variable/space-grotesk@5.3.0": {
"integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw=="
},
"@fontsource/comic-neue@5.3.0": {
"integrity": "sha512-uZsyUTExw8tCySNhL65dN6rEUhASMPGintSS5Kw/ZkrQTK1vcAgramzOLT/mZKtZ+EXr03MHj0s5osVVJITCow=="
},
"@fontsource/instrument-serif@5.3.0": {
"integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ=="
},
"@fontsource/manufacturing-consent@5.3.0": {
"integrity": "sha512-p/omJ13hFgV6+THlT370nyHjlMkrZWkKPiMSZHROcyFoylVvazo19Rp9KJhwudWqcbkClgzjEryvC330y4Pr6Q=="
},
"@humanfs/core@0.19.2": {
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dependencies": [
@@ -647,14 +663,14 @@
"use-sync-external-store"
]
},
"@lingui/swc-plugin@6.7.0_@lingui+core@6.8.0__@babel+core@7.29.7_@babel+core@7.29.7": {
"@lingui/swc-plugin@6.7.0_@lingui+core@6.8.0_@babel+core@7.29.7": {
"integrity": "sha512-TRLHfeQT/q9JXy0cBGOtbfcEWJFF1szjPCVM4AT1HdZyfGj+dQ0r8hHq+08c4iXZLzlsYilzw+bZJ3TahG5g5w==",
"dependencies": [
"@lingui/conf",
"@lingui/core"
]
},
"@lingui/vite-plugin@6.8.0_@babel+core@7.29.7_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": {
"@lingui/vite-plugin@6.8.0_@babel+core@7.29.7_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": {
"integrity": "sha512-pyOGwY8llaXzBBDrbZpAV5a0z7ekz0zJxU+EGXxyqL/gLneqHgLhPCv8uNB+MyIGwrw0PC9CMtCpHVlS3oWguA==",
"dependencies": [
"@babel/core",
@@ -869,7 +885,7 @@
"pretty-format@27.5.1"
]
},
"@testing-library/jest-dom@7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.6.3__@vitest+coverage-v8@5.0.2__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0_@vitest+coverage-v8@5.0.2__vitest@5.0.2__@types+node@26.6.3__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0": {
"@testing-library/jest-dom@7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2_@vitest+coverage-v8@5.0.2": {
"integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
"dependencies": [
"@adobe/css-tools",
@@ -885,7 +901,7 @@
"vitest"
]
},
"@testing-library/react@16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0__@types+react@19.3.0_react@19.3.0_react-dom@19.3.0__react@19.3.0": {
"@testing-library/react@16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": {
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dependencies": [
"@babel/runtime",
@@ -1003,7 +1019,7 @@
"@types/yargs-parser"
]
},
"@typescript-eslint/eslint-plugin@8.70.1_@typescript-eslint+parser@8.70.1__eslint@10.11.0___jiti@2.7.0__typescript@6.0.3_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": {
"@typescript-eslint/eslint-plugin@8.70.1_@typescript-eslint+parser@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==",
"dependencies": [
"@eslint-community/regexpp",
@@ -1019,7 +1035,7 @@
"typescript"
]
},
"@typescript-eslint/parser@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3": {
"@typescript-eslint/parser@8.70.1_eslint@10.11.0_typescript@6.0.3": {
"integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==",
"dependencies": [
"@typescript-eslint/scope-manager",
@@ -1053,7 +1069,7 @@
"typescript"
]
},
"@typescript-eslint/type-utils@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": {
"@typescript-eslint/type-utils@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==",
"dependencies": [
"@typescript-eslint/types",
@@ -1083,7 +1099,7 @@
"typescript"
]
},
"@typescript-eslint/utils@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": {
"@typescript-eslint/utils@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==",
"dependencies": [
"@eslint-community/eslint-utils",
@@ -1105,7 +1121,7 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"deprecated": true
},
"@vitejs/plugin-react-swc@4.3.3_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": {
"@vitejs/plugin-react-swc@4.3.3_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": {
"integrity": "sha512-bti8ZAcvz4Lh6/e4Uk2k3aa1TiUXbbMsahuqOHvd3MveFTkKDZOA6wQVkpj7J/+tepX/wGfe+lsGh/t24HTXMQ==",
"dependencies": [
"@rolldown/pluginutils",
@@ -1113,7 +1129,7 @@
"vite"
]
},
"@vitest/coverage-v8@5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0": {
"@vitest/coverage-v8@5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1": {
"integrity": "sha512-3ffHBEi8DOOBLwIGBhOBZbRfYFYWjMUuxZicONdhuFEFEG5AVsMOSrVeuROskqnqpbOmEJwxM2eTVZ9N3a1t+A==",
"dependencies": [
"@bcoe/v8-coverage",
@@ -1136,7 +1152,7 @@
"@vitest/istanbul-lib-coverage"
]
},
"@vitest/mocker@5.0.2_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": {
"@vitest/mocker@5.0.2_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": {
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
"dependencies": [
"@jridgewell/trace-mapping",
@@ -1428,7 +1444,7 @@
"escape-string-regexp@5.0.0": {
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="
},
"eslint-plugin-react-hooks@7.1.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": {
"eslint-plugin-react-hooks@7.1.1_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dependencies": [
"@babel/core",
@@ -1439,7 +1455,7 @@
"zod-validation-error"
]
},
"eslint-plugin-react-refresh@0.5.7_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": {
"eslint-plugin-react-refresh@0.5.7_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==",
"dependencies": [
"eslint"
@@ -2569,7 +2585,7 @@
"vfile"
]
},
"react-router@8.4.0_react@19.3.0_react-dom@19.3.0__react@19.3.0": {
"react-router@8.4.0_react@19.3.0_react-dom@19.3.0": {
"integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==",
"dependencies": [
"@remix-run/route-pattern",
@@ -2834,7 +2850,7 @@
"prelude-ls"
]
},
"typescript-eslint@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": {
"typescript-eslint@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==",
"dependencies": [
"@typescript-eslint/eslint-plugin",
@@ -2955,7 +2971,7 @@
],
"bin": true
},
"vitest@5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0": {
"vitest@5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1": {
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
"dependencies": [
"@types/chai",
@@ -3069,11 +3085,15 @@
"packageJson": {
"dependencies": [
"npm:@eslint/js@^10.0.1",
"npm:@fontsource-variable/fraunces@5.3.0",
"npm:@fontsource-variable/bricolage-grotesque@5.3.0",
"npm:@fontsource-variable/jetbrains-mono@5.3.0",
"npm:@fontsource-variable/libre-franklin@5.3.0",
"npm:@fontsource-variable/newsreader@5.3.0",
"npm:@fontsource-variable/saira@5.3.0",
"npm:@fontsource-variable/space-grotesk@5.3.0",
"npm:@fontsource/comic-neue@5.3.0",
"npm:@fontsource/instrument-serif@5.3.0",
"npm:@fontsource/manufacturing-consent@5.3.0",
"npm:@lingui/cli@6.8.0",
"npm:@lingui/conf@6.8.0",
"npm:@lingui/core@6.8.0",

View File

@@ -9,6 +9,11 @@
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png?v=__ICON_VERSION__" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png?v=__ICON_VERSION__" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Keeps the Dark Reader extension off the page, which has dark palettes
of its own: it rewrites the stylesheet's colors, and its rewrite of
the fallback the build gives every light-dark() leaves them all
invalid under a dark system theme, so no background is painted. -->
<meta name="darkreader-lock" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#01152a" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#faf5e9" />
<meta name="site-name" content="__SITE_NAME__" />

File diff suppressed because one or more lines are too long

View File

@@ -13,18 +13,18 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
#: src/components/comment/CommentNode.tsx:140
#: src/components/comment/CommentNode.tsx:137
msgid "[deleted]"
msgstr "[deleted]"
#. placeholder {0}: dump.commentCount
#: src/components/dump/DumpCard.tsx:135
#: src/components/dump/JournalCard.tsx:136
#: src/components/dump/JournalCard.tsx:137
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:120
#: src/components/playlist/PlaylistCard.tsx:141
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}"
@@ -60,7 +60,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}."
#: src/pages/Search.tsx:178
#: src/pages/Search.tsx:215
msgid "{label} ({count})"
msgstr "{label} ({count})"
@@ -206,14 +206,14 @@ msgstr "# Welcome to {site}!\n"
"Your account has been created successfully. Welcome aboard!"
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98
#: src/pages/UserPublicProfile.tsx:125
#: src/pages/PlaylistDetail.tsx:99
#: src/pages/UserPublicProfile.tsx:129
msgid "← Back"
msgstr "← Back"
#: src/pages/Dump.tsx:353
#: src/pages/Dump.tsx:378
#: src/pages/Dump.tsx:605
#: src/pages/Dump.tsx:374
#: src/pages/Dump.tsx:399
#: src/pages/Dump.tsx:560
#: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
@@ -226,22 +226,42 @@ msgstr "+ Invite someone"
msgid "+ New playlist"
msgstr "+ New playlist"
#: src/pages/Dump.tsx:452
#: src/pages/Dump.tsx:451
msgid "+ Playlist"
msgstr "+ Playlist"
#. placeholder {0}: d.commenterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:114
#: src/pages/Notifications.tsx:239
msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> commented on <1>{1}</1>"
#. placeholder {0}: event.username
#. placeholder {1}: event.playlist.title
#: src/components/social/CommunityFeed.tsx:127
msgid "<0>{0}</0> created the playlist <1>{1}</1>"
msgstr "<0>{0}</0> created the playlist <1>{1}</1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:199
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
#. placeholder {0}: event.username
#: src/components/social/CommunityFeed.tsx:145
msgid "<0>{0}</0> joined"
msgstr "<0>{0}</0> joined"
#. placeholder {0}: event.username
#. placeholder {1}: event.invitedByUsername
#: src/components/social/CommunityFeed.tsx:138
msgid "<0>{0}</0> joined, invited by <1>{1}</1>"
msgstr "<0>{0}</0> joined, invited by <1>{1}</1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:249
@@ -259,7 +279,10 @@ msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:94
#: src/pages/Notifications.tsx:209
msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> posted <1>{1}</1>"
@@ -270,7 +293,10 @@ msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> started following you"
#. placeholder {0}: d.voterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:104
#: src/pages/Notifications.tsx:229
msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
@@ -285,6 +311,19 @@ msgstr "<0>{0}</0> was added to <1>{1}</1>"
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
msgstr "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
#: src/components/social/CommunityFeed.tsx:159
msgid "<0>Online now:</0> {names}"
msgstr "<0>Online now:</0> {names}"
#: src/components/social/CommunityFeed.tsx:166
msgid "<0>This week:</0> <1>{figures}</1>"
msgstr "<0>This week:</0> <1>{figures}</1>"
#. placeholder {0}: top.title
#: src/components/social/CommunityFeed.tsx:174
msgid "<0>Top this week:</0> <1>{0}</1>"
msgstr "<0>Top this week:</0> <1>{0}</1>"
#: src/components/chat/ChatModal.tsx:778
msgid "1 new message"
msgstr "1 new message"
@@ -305,7 +344,7 @@ msgstr "a post"
msgid "Account"
msgstr "Account"
#: src/components/profile/ProfileHeader.tsx:144
#: src/components/profile/ProfileHeader.tsx:151
msgid "Account on <0>{host}</0>"
msgstr "Account on <0>{host}</0>"
@@ -313,12 +352,17 @@ msgstr "Account on <0>{host}</0>"
msgid "Add a comment…"
msgstr "Add a comment…"
#: src/components/playlist/PlaylistHeader.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:128
msgid "Add a description…"
msgstr "Add a description…"
#: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116
msgid "Add category"
msgstr "Add category"
#: src/components/profile/ProfileHeader.tsx:173
#: src/components/profile/ProfileHeader.tsx:168
msgid "Add email…"
msgstr "Add email…"
@@ -345,10 +389,6 @@ msgstr "Admin"
msgid "All"
msgstr "All"
#: src/components/layout/UserMenu.tsx:108
msgid "All playlists"
msgstr "All playlists"
#. placeholder {0}: first.ownerUsername
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/dump/create/DuplicateNotice.tsx:39
@@ -419,8 +459,8 @@ msgstr "by @{username}"
msgid "email.digest.playlistMeta"
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
#: src/contexts/ws/WSProvider.tsx:376
#: src/contexts/ws/WSProvider.tsx:765
#: src/contexts/ws/WSProvider.tsx:377
#: src/contexts/ws/WSProvider.tsx:770
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
@@ -431,16 +471,13 @@ msgstr "Can't sign in there"
#: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92
#: src/components/comment/CommentForm.tsx:127
#: src/components/comment/CommentForm.tsx:125
#: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:318
#: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:494
#: src/pages/DumpEdit.tsx:561
msgid "Cancel"
msgstr "Cancel"
@@ -466,7 +503,7 @@ msgstr "Categories"
msgid "Category"
msgstr "Category"
#: src/components/profile/ProfileHeader.tsx:102
#: src/components/profile/ProfileHeader.tsx:109
msgid "Change avatar"
msgstr "Change avatar"
@@ -525,7 +562,7 @@ msgstr "Close anyway"
msgid "Close emoji picker"
msgstr "Close emoji picker"
#: src/components/player/GlobalPlayer.tsx:262
#: src/components/player/GlobalPlayer.tsx:264
msgid "Close player"
msgstr "Close player"
@@ -533,7 +570,7 @@ msgstr "Close player"
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Close without posting? The attached file won't be kept — everything you typed will."
#: src/components/player/GlobalPlayer.tsx:254
#: src/components/player/GlobalPlayer.tsx:256
msgid "Collapse player"
msgstr "Collapse player"
@@ -555,7 +592,7 @@ msgstr "Confirm new password"
msgid "Connecting…"
msgstr "Connecting…"
#: src/contexts/ws/WSProvider.tsx:758
#: src/contexts/ws/WSProvider.tsx:763
msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent"
@@ -583,7 +620,7 @@ msgstr "Could not change password"
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."
#: src/components/profile/ProfilePlaylistsTab.tsx:171
#: src/components/profile/ProfilePlaylistsTab.tsx:194
#: src/pages/DumpEdit.tsx:555
msgid "Could not delete"
msgstr "Could not delete"
@@ -632,6 +669,10 @@ msgstr "Create"
msgid "Create & Add"
msgstr "Create & Add"
#: src/components/playlist/PlaylistCard.tsx:152
msgid "created {ago}"
msgstr "created {ago}"
#: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…"
msgstr "Creating…"
@@ -652,9 +693,9 @@ msgstr "Default tab"
#: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118
#: 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:271
#: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34
msgid "Delete"
@@ -679,14 +720,14 @@ msgstr "Delete dump"
msgid "Delete failed ({0})"
msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:149
#: src/components/playlist/PlaylistCard.tsx:172
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/pages/PlaylistDetail.tsx:172
#: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:174
msgid "Delete playlist"
msgstr "Delete playlist"
#: src/components/comment/CommentNode.tsx:270
#: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?"
msgstr "Delete this comment?"
@@ -694,12 +735,12 @@ msgstr "Delete this comment?"
msgid "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?"
msgstr "Delete this message?"
#: src/components/profile/ProfilePlaylistsTab.tsx:200
#: src/pages/PlaylistDetail.tsx:171
#: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:173
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
@@ -737,7 +778,7 @@ msgid "Done"
msgstr "Done"
#. placeholder {0}: dump.fileName
#: src/components/dump/FilePreview.tsx:226
#: src/components/dump/FilePreview.tsx:233
msgid "Download {0}"
msgstr "Download {0}"
@@ -770,8 +811,8 @@ msgid "Dumped!"
msgstr "Dumped!"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:174
#: src/pages/UserPublicProfile.tsx:160
#: src/pages/Search.tsx:211
#: src/pages/UserPublicProfile.tsx:164
msgid "Dumps"
msgstr "Dumps"
@@ -787,9 +828,9 @@ msgstr "Earlier"
#: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:601
#: src/components/comment/CommentNode.tsx:250
#: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:556
msgid "Edit"
msgstr "Edit"
@@ -798,16 +839,22 @@ msgstr "Edit"
msgid "Edit {0}"
msgstr "Edit {0}"
#: src/components/profile/ProfileDescription.tsx:58
#: src/components/playlist/PlaylistHeader.tsx:113
#: src/components/profile/ProfileDescription.tsx:45
msgid "Edit description"
msgstr "Edit description"
#: src/components/profile/ProfileHeader.tsx:171
#: src/components/profile/ProfileHeader.tsx:161
msgid "Edit email"
msgstr "Edit email"
#: src/pages/Dump.tsx:510
msgid "Edit note"
msgstr "Edit note"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:511
#: src/components/playlist/PlaylistHeader.tsx:84
#: src/pages/Dump.tsx:459
msgid "Edit title"
msgstr "Edit title"
@@ -819,9 +866,9 @@ msgstr "edited"
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:554
#: src/components/comment/CommentNode.tsx:202
#: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:492
msgid "edited {0}"
msgstr "edited {0}"
@@ -829,9 +876,9 @@ msgstr "edited {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:552
#: src/components/comment/CommentNode.tsx:200
#: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:490
msgid "Edited {0}"
msgstr "Edited {0}"
@@ -852,11 +899,11 @@ msgstr "Email"
msgid "Email address"
msgstr "Email address"
#: src/pages/Search.tsx:230
#: src/pages/Search.tsx:267
msgid "Enter a query to search."
msgstr "Enter a query to search."
#: src/components/player/GlobalPlayer.tsx:254
#: src/components/player/GlobalPlayer.tsx:256
msgid "Expand player"
msgstr "Expand player"
@@ -890,15 +937,13 @@ msgstr "Failed to post"
msgid "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"
msgstr "Failed to post reply"
#: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save"
msgstr "Failed to save"
@@ -906,11 +951,7 @@ msgstr "Failed to save"
msgid "Failed to save category"
msgstr "Failed to save category"
#: src/components/comment/CommentNode.tsx:218
msgid "Failed to save edit"
msgstr "Failed to save edit"
#: src/components/profile/ProfileHeader.tsx:182
#: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to update avatar"
msgstr "Failed to update avatar"
@@ -952,9 +993,9 @@ msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here."
#: src/components/feed/FeedTabBar.tsx:61
#: src/components/profile/ProfileFollowTab.tsx:144
#: src/components/profile/ProfileFollowTab.tsx:143
#: src/pages/Settings.tsx:57
#: src/pages/UserPublicProfile.tsx:163
#: src/pages/UserPublicProfile.tsx:167
msgid "Followed"
msgstr "Followed"
@@ -1025,7 +1066,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:621
#: src/pages/Dump.tsx:576
msgid "In collections"
msgstr "In collections"
@@ -1056,7 +1097,7 @@ msgstr "Invalid link"
msgid "Invite link"
msgstr "Invite link"
#: src/components/profile/ProfileHeader.tsx:126
#: src/components/profile/ProfileHeader.tsx:133
msgid "invited by"
msgstr "invited by"
@@ -1071,7 +1112,7 @@ msgid "digest.invitedByYou"
msgstr "invited by you"
#: src/components/profile/ProfileInviteesTab.tsx:77
#: src/pages/UserPublicProfile.tsx:164
#: src/pages/UserPublicProfile.tsx:168
msgid "Invitees"
msgstr "Invitees"
@@ -1110,11 +1151,11 @@ msgstr "Link"
msgid "Link or file"
msgstr "Link or file"
#: src/contexts/ws/WSProvider.tsx:764
#: src/contexts/ws/WSProvider.tsx:769
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
#: src/components/layout/AppHeader.tsx:234
#: src/components/layout/AppHeader.tsx:236
msgid "Live updates unavailable."
msgstr "Live updates unavailable."
@@ -1126,7 +1167,7 @@ msgstr "Load more"
msgid "Load older messages"
msgstr "Load older messages"
#: src/pages/Dump.tsx:337
#: src/pages/Dump.tsx:358
#: src/pages/DumpEdit.tsx:210
msgid "Loading dump…"
msgstr "Loading dump…"
@@ -1139,11 +1180,11 @@ msgstr "Loading messages…"
msgid "Loading more…"
msgstr "Loading more…"
#: src/pages/PlaylistDetail.tsx:82
#: src/pages/PlaylistDetail.tsx:83
msgid "Loading playlist…"
msgstr "Loading playlist…"
#: src/pages/UserPublicProfile.tsx:108
#: src/pages/UserPublicProfile.tsx:112
msgid "Loading profile…"
msgstr "Loading profile…"
@@ -1180,8 +1221,8 @@ msgstr "Log in to vote"
msgid "Log in with {0}"
msgstr "Log in with {0}"
#: src/components/layout/UserMenu.tsx:139
#: src/pages/UserPublicProfile.tsx:133
#: src/components/layout/UserMenu.tsx:132
#: src/pages/UserPublicProfile.tsx:137
msgid "Log out"
msgstr "Log out"
@@ -1198,10 +1239,10 @@ msgstr "Login failed"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:126
#: src/components/layout/UserMenu.tsx:119
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166
#: src/pages/UserPublicProfile.tsx:170
msgid "Manage"
msgstr "Manage"
@@ -1209,6 +1250,10 @@ msgstr "Manage"
msgid "Max {limit}"
msgstr "Max {limit}"
#: src/components/playlist/PlaylistSortTabs.tsx:34
msgid "Mine"
msgstr "Mine"
#: src/components/profile/RoleSwitcher.tsx:23
msgid "Moderator"
msgstr "Moderator"
@@ -1218,6 +1263,10 @@ msgstr "Moderator"
msgid "digest.monthly"
msgstr "Monthly recap"
#: src/components/playlist/PlaylistSortTabs.tsx:33
msgid "Most dumps"
msgstr "Most dumps"
#: src/components/player/MediaPlayer.tsx:299
msgid "Mute"
msgstr "Mute"
@@ -1229,6 +1278,7 @@ msgstr "Name"
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:32
#: src/pages/Settings.tsx:55
msgid "New"
msgstr "New"
@@ -1264,7 +1314,7 @@ msgstr "New public playlists"
msgid "Next →"
msgstr "Next →"
#: src/components/player/GlobalPlayer.tsx:244
#: src/components/player/GlobalPlayer.tsx:246
msgid "Next track"
msgstr "Next track"
@@ -1272,7 +1322,7 @@ msgstr "Next track"
msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet."
#: src/pages/Search.tsx:246
#: src/pages/Search.tsx:283
msgid "No dumps match \"{q}\"."
msgstr "No dumps match \"{q}\"."
@@ -1285,7 +1335,7 @@ msgstr "No dumps yet. Be the first!"
msgid "No emoji found."
msgstr "No emoji found."
#: src/components/profile/ProfileFollowTab.tsx:117
#: src/components/profile/ProfileFollowTab.tsx:116
msgid "No followed playlists yet."
msgstr "No followed playlists yet."
@@ -1301,21 +1351,21 @@ msgstr "No messages yet. Say hello!"
msgid "No one yet."
msgstr "No one yet."
#: src/pages/Search.tsx:287
#: src/pages/Search.tsx:330
msgid "No playlists match \"{q}\"."
msgstr "No playlists match \"{q}\"."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:178
#: src/pages/Playlists.tsx:126
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/pages/Playlists.tsx:180
msgid "No playlists yet."
msgstr "No playlists yet."
#: src/contexts/ws/WSProvider.tsx:934
#: src/contexts/ws/WSProvider.tsx:939
msgid "No reply from the server — this message may not have been sent"
msgstr "No reply from the server — this message may not have been sent"
#: src/pages/Search.tsx:261
#: src/pages/Search.tsx:298
msgid "No users match \"{q}\"."
msgstr "No users match \"{q}\"."
@@ -1379,11 +1429,11 @@ msgid "email.welcome.button"
msgstr "Open {site}"
#. placeholder {0}: richContent.siteName
#: src/components/dump/RichContentCard.tsx:122
#: src/components/dump/RichContentCard.tsx:124
msgid "Open on {0}"
msgstr "Open on {0}"
#: src/components/dump/RichContentCard.tsx:123
#: src/components/dump/RichContentCard.tsx:125
msgid "Open source"
msgstr "Open source"
@@ -1408,7 +1458,7 @@ msgstr "Or log in with your account on another gerbeur"
msgid "Other instances"
msgstr "Other instances"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:163
msgid "Overview"
msgstr "Overview"
@@ -1444,9 +1494,9 @@ msgstr "Passwords do not match"
msgid "Paste a link…"
msgstr "Paste a link…"
#: src/components/dump/FilePreview.tsx:70
#: src/components/dump/FilePreview.tsx:184
#: src/components/dump/RichContentCard.tsx:103
#: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:105
#: src/components/player/MediaPlayer.tsx:276
msgid "Pause"
msgstr "Pause"
@@ -1455,10 +1505,10 @@ msgstr "Pause"
msgid "Picked up where you left off."
msgstr "Picked up where you left off."
#: src/components/dump/FilePreview.tsx:70
#: src/components/dump/FilePreview.tsx:184
#: src/components/dump/RichContentCard.tsx:103
#: src/components/dump/RichContentCard.tsx:150
#: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:105
#: src/components/dump/RichContentCard.tsx:153
#: src/components/player/MediaPlayer.tsx:276
msgid "Play"
msgstr "Play"
@@ -1468,14 +1518,14 @@ msgid "Playlist categories"
msgstr "Playlist categories"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:172
#: src/components/layout/UserMenu.tsx:92
#: src/components/profile/ProfileFollowTab.tsx:148
#: src/components/profile/ProfilePlaylistsTab.tsx:149
#: src/pages/Playlists.tsx:53
#: src/pages/Playlists.tsx:114
#: src/pages/Search.tsx:177
#: src/pages/UserPublicProfile.tsx:162
#: src/components/layout/AppHeader.tsx:174
#: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:162
#: src/pages/Playlists.tsx:74
#: src/pages/Playlists.tsx:160
#: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:166
msgid "Playlists"
msgstr "Playlists"
@@ -1483,26 +1533,26 @@ msgstr "Playlists"
msgid "Post comment"
msgstr "Post comment"
#: src/components/comment/CommentNode.tsx:289
#: src/components/comment/CommentNode.tsx:283
msgid "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/dump/create/DumpCreateModal.tsx:752
msgid "Posting…"
msgstr "Posting…"
#: src/components/player/GlobalPlayer.tsx:228
#: src/components/player/GlobalPlayer.tsx:230
msgid "Previous track"
msgstr "Previous track"
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:106
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/dump/JournalCard.tsx:146
#: src/components/playlist/PlaylistCard.tsx:127
#: src/components/playlist/PlaylistHeader.tsx:140
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:560
#: src/pages/Dump.tsx:498
msgid "private"
msgstr "private"
@@ -1510,8 +1560,8 @@ msgstr "private"
msgid "Private"
msgstr "Private"
#: src/components/playlist/PlaylistCard.tsx:105
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistCard.tsx:126
#: src/components/playlist/PlaylistHeader.tsx:140
msgid "public"
msgstr "public"
@@ -1523,7 +1573,7 @@ msgstr "Public"
msgid "Refresh metadata"
msgstr "Refresh metadata"
#: src/components/player/GlobalPlayer.tsx:324
#: src/components/player/GlobalPlayer.tsx:326
msgid "Refreshing stream…"
msgstr "Refreshing stream…"
@@ -1545,7 +1595,7 @@ msgstr "Registering…"
msgid "Registration failed"
msgstr "Registration failed"
#: src/pages/Dump.tsx:612
#: src/pages/Dump.tsx:567
msgid "Related"
msgstr "Related"
@@ -1585,7 +1635,7 @@ msgstr "Replace file"
#: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244
#: src/components/comment/CommentNode.tsx:240
msgid "Reply"
msgstr "Reply"
@@ -1632,7 +1682,7 @@ msgid "email.reset.title"
msgstr "Reset your password"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:371
#: src/pages/Dump.tsx:392
#: src/pages/DumpEdit.tsx:227
msgid "Retry"
msgstr "Retry"
@@ -1642,29 +1692,22 @@ msgid "Role"
msgstr "Role"
#: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216
#: src/components/form/InlineEdit.tsx:310
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:486
#: src/pages/DumpEdit.tsx:564
msgid "Save"
msgstr "Save"
#: src/components/comment/CommentNode.tsx:217
#: src/components/form/InlineEdit.tsx:307
#: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:485
#: src/pages/ResetPassword.tsx:191
msgid "Saving…"
msgstr "Saving…"
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:90
#: src/pages/Search.tsx:106
msgid "Search"
msgstr "Search"
@@ -1680,11 +1723,11 @@ msgstr "Search emoji"
msgid "Search emoji…"
msgstr "Search emoji…"
#: src/pages/Search.tsx:241
#: src/pages/Search.tsx:278
msgid "Search failed"
msgstr "Search failed"
#: src/pages/Search.tsx:236
#: src/pages/Search.tsx:273
msgid "Searching…"
msgstr "Searching…"
@@ -1724,7 +1767,7 @@ msgstr "Set new password"
msgid "email.settings"
msgstr "Settings"
#: src/components/layout/UserMenu.tsx:116
#: src/components/layout/UserMenu.tsx:109
#: src/pages/Settings.tsx:75
#: src/pages/Settings.tsx:88
msgid "Settings"
@@ -1766,8 +1809,8 @@ msgstr "Someone asked to reset the password of your {site} account. The button b
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:733
#: src/pages/PlaylistDetail.tsx:155
#: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:157
msgid "Something went wrong"
msgstr "Something went wrong"
@@ -1827,11 +1870,11 @@ msgstr "The month on {site}, {range}"
msgid "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."
msgstr "This comment couldn't be deleted. Try again."
#: src/pages/Dump.tsx:346
#: src/pages/Dump.tsx:367
msgid "This dump was deleted, or made private."
msgstr "This dump was deleted, or made private."
@@ -1902,7 +1945,7 @@ msgid "digest.topWeek"
msgstr "Top this week"
#. placeholder {0}: i + 1
#: src/components/player/GlobalPlayer.tsx:338
#: src/components/player/GlobalPlayer.tsx:340
msgid "Track {0}"
msgstr "Track {0}"
@@ -1972,12 +2015,20 @@ msgstr "Unsubscribe"
msgid "Unsubscribed"
msgstr "Unsubscribed"
#: src/components/playlist/PlaylistSortTabs.tsx:31
msgid "Updated"
msgstr "Updated"
#: src/components/playlist/PlaylistCard.tsx:151
msgid "updated {ago}"
msgstr "updated {ago}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:237
msgid "email.digest.upload"
msgstr "upload"
#: src/components/profile/ProfileHeader.tsx:79
#: src/components/profile/ProfileHeader.tsx:80
msgid "Upload failed"
msgstr "Upload failed"
@@ -1999,7 +2050,7 @@ msgid "Upvote"
msgstr "Upvote"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/UserPublicProfile.tsx:161
#: src/pages/UserPublicProfile.tsx:165
msgid "Upvoted"
msgstr "Upvoted"
@@ -2028,7 +2079,7 @@ msgstr "User"
msgid "User management"
msgstr "User management"
#: src/components/layout/UserMenu.tsx:67
#: src/components/layout/UserMenu.tsx:68
msgid "User menu"
msgstr "User menu"
@@ -2040,8 +2091,8 @@ msgstr "User menu"
msgid "Username"
msgstr "Username"
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/pages/Search.tsx:176
#: src/components/profile/ProfileFollowTab.tsx:146
#: src/pages/Search.tsx:213
msgid "Users"
msgstr "Users"
@@ -2080,12 +2131,14 @@ msgid "email.welcome.subject"
msgstr "Welcome to {site}"
#: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/Dump.tsx:514
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:539
msgid "What makes it worth it?"
msgstr "What makes it worth it?"
#: src/components/profile/DescriptionEditor.tsx:78
#: src/components/profile/ProfileDescription.tsx:79
#: src/components/profile/ProfileDescription.tsx:62
#: src/components/profile/ProfileDescription.tsx:72
msgid "Who am I?"
msgstr "Who am I?"
@@ -2102,7 +2155,7 @@ msgstr "Why & where"
msgid "Why?"
msgstr "Why?"
#: src/components/comment/CommentNode.tsx:293
#: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…"
msgstr "Write a reply…"
@@ -2151,7 +2204,7 @@ msgstr "You're getting this email because a password reset was requested for thi
msgid "email.welcome.reason"
msgstr "You're getting this email because an account was created on {site} with this address."
#: src/contexts/ws/WSProvider.tsx:915
#: src/contexts/ws/WSProvider.tsx:920
msgid "You're offline — not sent"
msgstr "You're offline — not sent"

File diff suppressed because one or more lines are too long

View File

@@ -13,18 +13,18 @@ msgstr ""
"Last-Translator: \n"
"Language-Team: \n"
#: src/components/comment/CommentNode.tsx:140
#: src/components/comment/CommentNode.tsx:137
msgid "[deleted]"
msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount
#: src/components/dump/DumpCard.tsx:135
#: src/components/dump/JournalCard.tsx:136
#: src/components/dump/JournalCard.tsx:137
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:120
#: src/components/playlist/PlaylistCard.tsx:141
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}"
@@ -60,7 +60,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}."
#: src/pages/Search.tsx:178
#: src/pages/Search.tsx:215
msgid "{label} ({count})"
msgstr "{label} ({count})"
@@ -206,14 +206,14 @@ msgstr "# Bienvenue sur {site} !\n"
"Votre compte a bien été créé. Bienvenue à bord !"
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98
#: src/pages/UserPublicProfile.tsx:125
#: src/pages/PlaylistDetail.tsx:99
#: src/pages/UserPublicProfile.tsx:129
msgid "← Back"
msgstr "← Retour"
#: src/pages/Dump.tsx:353
#: src/pages/Dump.tsx:378
#: src/pages/Dump.tsx:605
#: src/pages/Dump.tsx:374
#: src/pages/Dump.tsx:399
#: src/pages/Dump.tsx:560
#: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
@@ -226,22 +226,42 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist"
msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:452
#: src/pages/Dump.tsx:451
msgid "+ Playlist"
msgstr "+ Collection"
#. placeholder {0}: d.commenterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:114
#: src/pages/Notifications.tsx:239
msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
#. placeholder {0}: event.username
#. placeholder {1}: event.playlist.title
#: src/components/social/CommunityFeed.tsx:127
msgid "<0>{0}</0> created the playlist <1>{1}</1>"
msgstr "<0>{0}</0> a créé la playlist <1>{1}</1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:199
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
#. placeholder {0}: event.username
#: src/components/social/CommunityFeed.tsx:145
msgid "<0>{0}</0> joined"
msgstr "<0>{0}</0> a rejoint le site"
#. placeholder {0}: event.username
#. placeholder {1}: event.invitedByUsername
#: src/components/social/CommunityFeed.tsx:138
msgid "<0>{0}</0> joined, invited by <1>{1}</1>"
msgstr "<0>{0}</0> a rejoint le site, sur invitation de <1>{1}</1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:249
@@ -259,7 +279,10 @@ msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:94
#: src/pages/Notifications.tsx:209
msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> a publié <1>{1}</1>"
@@ -270,7 +293,10 @@ msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> a commencé à vous suivre"
#. placeholder {0}: d.voterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:104
#: src/pages/Notifications.tsx:229
msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> a validé <1>{1}</1>"
@@ -285,6 +311,19 @@ msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
msgstr "<0>{name}</0> ({host}) demande à vous connecter avec votre compte d'ici, <1>@{username}</1>. Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar."
#: src/components/social/CommunityFeed.tsx:159
msgid "<0>Online now:</0> {names}"
msgstr "<0>En ligne :</0> {names}"
#: src/components/social/CommunityFeed.tsx:166
msgid "<0>This week:</0> <1>{figures}</1>"
msgstr "<0>Cette semaine :</0> <1>{figures}</1>"
#. placeholder {0}: top.title
#: src/components/social/CommunityFeed.tsx:174
msgid "<0>Top this week:</0> <1>{0}</1>"
msgstr "<0>En tête cette semaine :</0> <1>{0}</1>"
#: src/components/chat/ChatModal.tsx:778
msgid "1 new message"
msgstr "1 nouveau message"
@@ -305,7 +344,7 @@ msgstr "une publication"
msgid "Account"
msgstr "Compte"
#: src/components/profile/ProfileHeader.tsx:144
#: src/components/profile/ProfileHeader.tsx:151
msgid "Account on <0>{host}</0>"
msgstr "Compte sur <0>{host}</0>"
@@ -313,12 +352,17 @@ msgstr "Compte sur <0>{host}</0>"
msgid "Add a comment…"
msgstr "Ajouter un commentaire…"
#: src/components/playlist/PlaylistHeader.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:128
msgid "Add a description…"
msgstr "Ajouter une description…"
#: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116
msgid "Add category"
msgstr "Ajouter une catégorie"
#: src/components/profile/ProfileHeader.tsx:173
#: src/components/profile/ProfileHeader.tsx:168
msgid "Add email…"
msgstr "Ajouter un e-mail…"
@@ -345,10 +389,6 @@ msgstr "Administrateur"
msgid "All"
msgstr "Tout"
#: src/components/layout/UserMenu.tsx:108
msgid "All playlists"
msgstr "Toutes les collections"
#. placeholder {0}: first.ownerUsername
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/dump/create/DuplicateNotice.tsx:39
@@ -419,8 +459,8 @@ msgstr "par @{username}"
msgid "email.digest.playlistMeta"
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
#: src/contexts/ws/WSProvider.tsx:376
#: src/contexts/ws/WSProvider.tsx:765
#: src/contexts/ws/WSProvider.tsx:377
#: src/contexts/ws/WSProvider.tsx:770
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
@@ -431,16 +471,13 @@ msgstr "Connexion impossible"
#: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92
#: src/components/comment/CommentForm.tsx:127
#: src/components/comment/CommentForm.tsx:125
#: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:318
#: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:494
#: src/pages/DumpEdit.tsx:561
msgid "Cancel"
msgstr "Annuler"
@@ -466,7 +503,7 @@ msgstr "Catégories"
msgid "Category"
msgstr "Catégorie"
#: src/components/profile/ProfileHeader.tsx:102
#: src/components/profile/ProfileHeader.tsx:109
msgid "Change avatar"
msgstr "Changer l'avatar"
@@ -525,7 +562,7 @@ msgstr "Fermer quand même"
msgid "Close emoji picker"
msgstr "Fermer le sélecteur d'emoji"
#: src/components/player/GlobalPlayer.tsx:262
#: src/components/player/GlobalPlayer.tsx:264
msgid "Close player"
msgstr "Fermer le lecteur"
@@ -533,7 +570,7 @@ msgstr "Fermer le lecteur"
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
#: src/components/player/GlobalPlayer.tsx:254
#: src/components/player/GlobalPlayer.tsx:256
msgid "Collapse player"
msgstr "Réduire le lecteur"
@@ -555,7 +592,7 @@ msgstr "Confirmer le nouveau mot de passe"
msgid "Connecting…"
msgstr "Connexion…"
#: src/contexts/ws/WSProvider.tsx:758
#: src/contexts/ws/WSProvider.tsx:763
msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
@@ -583,7 +620,7 @@ msgstr "Impossible de changer le mot de passe"
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."
#: src/components/profile/ProfilePlaylistsTab.tsx:171
#: src/components/profile/ProfilePlaylistsTab.tsx:194
#: src/pages/DumpEdit.tsx:555
msgid "Could not delete"
msgstr "Suppression impossible"
@@ -632,6 +669,10 @@ msgstr "Créer"
msgid "Create & Add"
msgstr "Créer et ajouter"
#: src/components/playlist/PlaylistCard.tsx:152
msgid "created {ago}"
msgstr "créée {ago}"
#: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…"
msgstr "Création…"
@@ -652,9 +693,9 @@ msgstr "Onglet par défaut"
#: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118
#: 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:271
#: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34
msgid "Delete"
@@ -679,14 +720,14 @@ msgstr "Supprimer la reco"
msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:149
#: src/components/playlist/PlaylistCard.tsx:172
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/pages/PlaylistDetail.tsx:172
#: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:174
msgid "Delete playlist"
msgstr "Supprimer la collection"
#: src/components/comment/CommentNode.tsx:270
#: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?"
@@ -694,12 +735,12 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone."
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?"
msgstr "Supprimer ce message ?"
#: src/components/profile/ProfilePlaylistsTab.tsx:200
#: src/pages/PlaylistDetail.tsx:171
#: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:173
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -737,7 +778,7 @@ msgid "Done"
msgstr "Terminé"
#. placeholder {0}: dump.fileName
#: src/components/dump/FilePreview.tsx:226
#: src/components/dump/FilePreview.tsx:233
msgid "Download {0}"
msgstr "Télécharger {0}"
@@ -770,8 +811,8 @@ msgid "Dumped!"
msgstr "Recommandé !"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:174
#: src/pages/UserPublicProfile.tsx:160
#: src/pages/Search.tsx:211
#: src/pages/UserPublicProfile.tsx:164
msgid "Dumps"
msgstr "Recos"
@@ -787,9 +828,9 @@ msgstr "Plus tôt"
#: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:601
#: src/components/comment/CommentNode.tsx:250
#: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:556
msgid "Edit"
msgstr "Modifier"
@@ -798,16 +839,22 @@ msgstr "Modifier"
msgid "Edit {0}"
msgstr "Modifier {0}"
#: src/components/profile/ProfileDescription.tsx:58
#: src/components/playlist/PlaylistHeader.tsx:113
#: src/components/profile/ProfileDescription.tsx:45
msgid "Edit description"
msgstr "Modifier la description"
#: src/components/profile/ProfileHeader.tsx:171
#: src/components/profile/ProfileHeader.tsx:161
msgid "Edit email"
msgstr "Modifier l'e-mail"
#: src/pages/Dump.tsx:510
msgid "Edit note"
msgstr "Modifier la note"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:511
#: src/components/playlist/PlaylistHeader.tsx:84
#: src/pages/Dump.tsx:459
msgid "Edit title"
msgstr "Modifier le titre"
@@ -819,9 +866,9 @@ msgstr "modifié"
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:554
#: src/components/comment/CommentNode.tsx:202
#: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:492
msgid "edited {0}"
msgstr "modifié {0}"
@@ -829,9 +876,9 @@ msgstr "modifié {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:552
#: src/components/comment/CommentNode.tsx:200
#: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:490
msgid "Edited {0}"
msgstr "Modifié le {0}"
@@ -852,11 +899,11 @@ msgstr "E-mail"
msgid "Email address"
msgstr "Adresse e-mail"
#: src/pages/Search.tsx:230
#: src/pages/Search.tsx:267
msgid "Enter a query to search."
msgstr "Saisissez une recherche."
#: src/components/player/GlobalPlayer.tsx:254
#: src/components/player/GlobalPlayer.tsx:256
msgid "Expand player"
msgstr "Agrandir le lecteur"
@@ -890,15 +937,13 @@ msgstr "Publication échouée"
msgid "Failed to post comment"
msgstr "Impossible de publier le commentaire"
#: src/components/comment/CommentNode.tsx:291
#: src/components/comment/CommentNode.tsx:285
msgid "Failed to post reply"
msgstr "Impossible de publier la réponse"
#: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save"
msgstr "Enregistrement échoué"
@@ -906,11 +951,7 @@ msgstr "Enregistrement échoué"
msgid "Failed to save category"
msgstr "Échec de l’enregistrement de la catégorie"
#: src/components/comment/CommentNode.tsx:218
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
#: src/components/profile/ProfileHeader.tsx:182
#: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar"
@@ -952,9 +993,9 @@ msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/feed/FeedTabBar.tsx:61
#: src/components/profile/ProfileFollowTab.tsx:144
#: src/components/profile/ProfileFollowTab.tsx:143
#: src/pages/Settings.tsx:57
#: src/pages/UserPublicProfile.tsx:163
#: src/pages/UserPublicProfile.tsx:167
msgid "Followed"
msgstr "Suivi"
@@ -1025,7 +1066,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:621
#: src/pages/Dump.tsx:576
msgid "In collections"
msgstr "Dans les collections"
@@ -1056,7 +1097,7 @@ msgstr "Lien invalide"
msgid "Invite link"
msgstr "Lien d'invitation"
#: src/components/profile/ProfileHeader.tsx:126
#: src/components/profile/ProfileHeader.tsx:133
msgid "invited by"
msgstr "invité par"
@@ -1071,7 +1112,7 @@ msgid "digest.invitedByYou"
msgstr "sur votre invitation"
#: src/components/profile/ProfileInviteesTab.tsx:77
#: src/pages/UserPublicProfile.tsx:164
#: src/pages/UserPublicProfile.tsx:168
msgid "Invitees"
msgstr "Invités"
@@ -1110,11 +1151,11 @@ msgstr "Lien"
msgid "Link or file"
msgstr "Lien ou fichier"
#: src/contexts/ws/WSProvider.tsx:764
#: src/contexts/ws/WSProvider.tsx:769
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
#: src/components/layout/AppHeader.tsx:234
#: src/components/layout/AppHeader.tsx:236
msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles."
@@ -1126,7 +1167,7 @@ msgstr "Charger plus"
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:337
#: src/pages/Dump.tsx:358
#: src/pages/DumpEdit.tsx:210
msgid "Loading dump…"
msgstr "Chargement de la reco…"
@@ -1139,11 +1180,11 @@ msgstr "Chargement des messages…"
msgid "Loading more…"
msgstr "Chargement…"
#: src/pages/PlaylistDetail.tsx:82
#: src/pages/PlaylistDetail.tsx:83
msgid "Loading playlist…"
msgstr "Chargement de la collection…"
#: src/pages/UserPublicProfile.tsx:108
#: src/pages/UserPublicProfile.tsx:112
msgid "Loading profile…"
msgstr "Chargement du profil…"
@@ -1180,8 +1221,8 @@ msgstr "Se connecter pour valider"
msgid "Log in with {0}"
msgstr "Se connecter avec {0}"
#: src/components/layout/UserMenu.tsx:139
#: src/pages/UserPublicProfile.tsx:133
#: src/components/layout/UserMenu.tsx:132
#: src/pages/UserPublicProfile.tsx:137
msgid "Log out"
msgstr "Se déconnecter"
@@ -1198,10 +1239,10 @@ msgstr "Connexion échouée"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:126
#: src/components/layout/UserMenu.tsx:119
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166
#: src/pages/UserPublicProfile.tsx:170
msgid "Manage"
msgstr "Administration"
@@ -1209,6 +1250,10 @@ msgstr "Administration"
msgid "Max {limit}"
msgstr "Max {limit}"
#: src/components/playlist/PlaylistSortTabs.tsx:34
msgid "Mine"
msgstr "Les miennes"
#: src/components/profile/RoleSwitcher.tsx:23
msgid "Moderator"
msgstr "Modérateur"
@@ -1218,6 +1263,10 @@ msgstr "Modérateur"
msgid "digest.monthly"
msgstr "Récap mensuel"
#: src/components/playlist/PlaylistSortTabs.tsx:33
msgid "Most dumps"
msgstr "Les plus fournies"
#: src/components/player/MediaPlayer.tsx:299
msgid "Mute"
msgstr "Couper le son"
@@ -1229,6 +1278,7 @@ msgstr "Nom"
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:32
#: src/pages/Settings.tsx:55
msgid "New"
msgstr "Nouveau"
@@ -1264,7 +1314,7 @@ msgstr "Nouvelles collections publiques"
msgid "Next →"
msgstr "Suivant →"
#: src/components/player/GlobalPlayer.tsx:244
#: src/components/player/GlobalPlayer.tsx:246
msgid "Next track"
msgstr "Piste suivante"
@@ -1272,7 +1322,7 @@ msgstr "Piste suivante"
msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant."
#: src/pages/Search.tsx:246
#: src/pages/Search.tsx:283
msgid "No dumps match \"{q}\"."
msgstr "Aucune reco ne correspond à « {q} »."
@@ -1285,7 +1335,7 @@ msgstr "Pas encore de recos. Soyez le premier !"
msgid "No emoji found."
msgstr "Aucun emoji trouvé."
#: src/components/profile/ProfileFollowTab.tsx:117
#: src/components/profile/ProfileFollowTab.tsx:116
msgid "No followed playlists yet."
msgstr "Pas encore de collections suivies."
@@ -1301,21 +1351,21 @@ msgstr "Aucun message pour l'instant. Dites bonjour !"
msgid "No one yet."
msgstr "Personne pour le moment."
#: src/pages/Search.tsx:287
#: src/pages/Search.tsx:330
msgid "No playlists match \"{q}\"."
msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:178
#: src/pages/Playlists.tsx:126
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/pages/Playlists.tsx:180
msgid "No playlists yet."
msgstr "Pas encore de collections."
#: src/contexts/ws/WSProvider.tsx:934
#: src/contexts/ws/WSProvider.tsx:939
msgid "No reply from the server — this message may not have been sent"
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
#: src/pages/Search.tsx:261
#: src/pages/Search.tsx:298
msgid "No users match \"{q}\"."
msgstr "Aucun utilisateur ne correspond à « {q} »."
@@ -1379,11 +1429,11 @@ msgid "email.welcome.button"
msgstr "Ouvrir {site}"
#. placeholder {0}: richContent.siteName
#: src/components/dump/RichContentCard.tsx:122
#: src/components/dump/RichContentCard.tsx:124
msgid "Open on {0}"
msgstr "Ouvrir sur {0}"
#: src/components/dump/RichContentCard.tsx:123
#: src/components/dump/RichContentCard.tsx:125
msgid "Open source"
msgstr "Ouvrir la source"
@@ -1408,7 +1458,7 @@ msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur"
msgid "Other instances"
msgstr "Autres instances"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:163
msgid "Overview"
msgstr "Aperçu"
@@ -1444,9 +1494,9 @@ msgstr "Les mots de passe ne correspondent pas"
msgid "Paste a link…"
msgstr "Collez un lien…"
#: src/components/dump/FilePreview.tsx:70
#: src/components/dump/FilePreview.tsx:184
#: src/components/dump/RichContentCard.tsx:103
#: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:105
#: src/components/player/MediaPlayer.tsx:276
msgid "Pause"
msgstr "Pause"
@@ -1455,10 +1505,10 @@ msgstr "Pause"
msgid "Picked up where you left off."
msgstr "Reprise de votre brouillon."
#: src/components/dump/FilePreview.tsx:70
#: src/components/dump/FilePreview.tsx:184
#: src/components/dump/RichContentCard.tsx:103
#: src/components/dump/RichContentCard.tsx:150
#: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:105
#: src/components/dump/RichContentCard.tsx:153
#: src/components/player/MediaPlayer.tsx:276
msgid "Play"
msgstr "Lecture"
@@ -1468,14 +1518,14 @@ msgid "Playlist categories"
msgstr "Catégories de collections"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:172
#: src/components/layout/UserMenu.tsx:92
#: src/components/profile/ProfileFollowTab.tsx:148
#: src/components/profile/ProfilePlaylistsTab.tsx:149
#: src/pages/Playlists.tsx:53
#: src/pages/Playlists.tsx:114
#: src/pages/Search.tsx:177
#: src/pages/UserPublicProfile.tsx:162
#: src/components/layout/AppHeader.tsx:174
#: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:162
#: src/pages/Playlists.tsx:74
#: src/pages/Playlists.tsx:160
#: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:166
msgid "Playlists"
msgstr "Collections"
@@ -1483,26 +1533,26 @@ msgstr "Collections"
msgid "Post comment"
msgstr "Publier le commentaire"
#: src/components/comment/CommentNode.tsx:289
#: src/components/comment/CommentNode.tsx:283
msgid "Post reply"
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/dump/create/DumpCreateModal.tsx:752
msgid "Posting…"
msgstr "Publication…"
#: src/components/player/GlobalPlayer.tsx:228
#: src/components/player/GlobalPlayer.tsx:230
msgid "Previous track"
msgstr "Piste précédente"
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:106
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/dump/JournalCard.tsx:146
#: src/components/playlist/PlaylistCard.tsx:127
#: src/components/playlist/PlaylistHeader.tsx:140
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:560
#: src/pages/Dump.tsx:498
msgid "private"
msgstr "privé"
@@ -1510,8 +1560,8 @@ msgstr "privé"
msgid "Private"
msgstr "Privé"
#: src/components/playlist/PlaylistCard.tsx:105
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistCard.tsx:126
#: src/components/playlist/PlaylistHeader.tsx:140
msgid "public"
msgstr "public"
@@ -1523,7 +1573,7 @@ msgstr "Public"
msgid "Refresh metadata"
msgstr "Actualiser les métadonnées"
#: src/components/player/GlobalPlayer.tsx:324
#: src/components/player/GlobalPlayer.tsx:326
msgid "Refreshing stream…"
msgstr "Actualisation du flux…"
@@ -1545,7 +1595,7 @@ msgstr "Inscription…"
msgid "Registration failed"
msgstr "Inscription échouée"
#: src/pages/Dump.tsx:612
#: src/pages/Dump.tsx:567
msgid "Related"
msgstr "Connexe"
@@ -1585,7 +1635,7 @@ msgstr "Remplacer le fichier"
#: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244
#: src/components/comment/CommentNode.tsx:240
msgid "Reply"
msgstr "Répondre"
@@ -1632,7 +1682,7 @@ msgid "email.reset.title"
msgstr "Réinitialiser votre mot de passe"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:371
#: src/pages/Dump.tsx:392
#: src/pages/DumpEdit.tsx:227
msgid "Retry"
msgstr "Réessayer"
@@ -1642,29 +1692,22 @@ msgid "Role"
msgstr "Rôle"
#: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216
#: src/components/form/InlineEdit.tsx:310
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:486
#: src/pages/DumpEdit.tsx:564
msgid "Save"
msgstr "Enregistrer"
#: src/components/comment/CommentNode.tsx:217
#: src/components/form/InlineEdit.tsx:307
#: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:485
#: src/pages/ResetPassword.tsx:191
msgid "Saving…"
msgstr "Enregistrement…"
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:90
#: src/pages/Search.tsx:106
msgid "Search"
msgstr "Rechercher"
@@ -1680,11 +1723,11 @@ msgstr "Rechercher un emoji"
msgid "Search emoji…"
msgstr "Rechercher un emoji…"
#: src/pages/Search.tsx:241
#: src/pages/Search.tsx:278
msgid "Search failed"
msgstr "Recherche échouée"
#: src/pages/Search.tsx:236
#: src/pages/Search.tsx:273
msgid "Searching…"
msgstr "Recherche…"
@@ -1724,7 +1767,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "email.settings"
msgstr "Paramètres"
#: src/components/layout/UserMenu.tsx:116
#: src/components/layout/UserMenu.tsx:109
#: src/pages/Settings.tsx:75
#: src/pages/Settings.tsx:88
msgid "Settings"
@@ -1766,8 +1809,8 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:733
#: src/pages/PlaylistDetail.tsx:155
#: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:157
msgid "Something went wrong"
msgstr "Une erreur est survenue"
@@ -1827,11 +1870,11 @@ msgstr "Le mois sur {site}, {range}"
msgid "The new order couldn't be saved. Try again."
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."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
#: src/pages/Dump.tsx:346
#: src/pages/Dump.tsx:367
msgid "This dump was deleted, or made private."
msgstr "Cette reco a été supprimée, ou rendue privée."
@@ -1902,7 +1945,7 @@ msgid "digest.topWeek"
msgstr "Les plus validées de la semaine"
#. placeholder {0}: i + 1
#: src/components/player/GlobalPlayer.tsx:338
#: src/components/player/GlobalPlayer.tsx:340
msgid "Track {0}"
msgstr "Piste {0}"
@@ -1972,12 +2015,20 @@ msgstr "Se désabonner"
msgid "Unsubscribed"
msgstr "Désabonné"
#: src/components/playlist/PlaylistSortTabs.tsx:31
msgid "Updated"
msgstr "Mises à jour"
#: src/components/playlist/PlaylistCard.tsx:151
msgid "updated {ago}"
msgstr "mise à jour {ago}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:237
msgid "email.digest.upload"
msgstr "fichier"
#: src/components/profile/ProfileHeader.tsx:79
#: src/components/profile/ProfileHeader.tsx:80
msgid "Upload failed"
msgstr "Envoi échoué"
@@ -1999,7 +2050,7 @@ msgid "Upvote"
msgstr "Valider"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/UserPublicProfile.tsx:161
#: src/pages/UserPublicProfile.tsx:165
msgid "Upvoted"
msgstr "Validé"
@@ -2028,7 +2079,7 @@ msgstr "Utilisateur"
msgid "User management"
msgstr "Gestion utilisateur"
#: src/components/layout/UserMenu.tsx:67
#: src/components/layout/UserMenu.tsx:68
msgid "User menu"
msgstr "Menu utilisateur"
@@ -2040,8 +2091,8 @@ msgstr "Menu utilisateur"
msgid "Username"
msgstr "Nom d'utilisateur"
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/pages/Search.tsx:176
#: src/components/profile/ProfileFollowTab.tsx:146
#: src/pages/Search.tsx:213
msgid "Users"
msgstr "Utilisateurs"
@@ -2080,12 +2131,14 @@ msgid "email.welcome.subject"
msgstr "Bienvenue sur {site}"
#: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/Dump.tsx:514
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:539
msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
#: src/components/profile/DescriptionEditor.tsx:78
#: src/components/profile/ProfileDescription.tsx:79
#: src/components/profile/ProfileDescription.tsx:62
#: src/components/profile/ProfileDescription.tsx:72
msgid "Who am I?"
msgstr "Qui suis-je ?"
@@ -2102,7 +2155,7 @@ msgstr "Pourquoi & où"
msgid "Why?"
msgstr "Pourquoi ?"
#: src/components/comment/CommentNode.tsx:293
#: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…"
msgstr "Écrire une réponse…"
@@ -2151,7 +2204,7 @@ msgstr "Vous recevez cet e-mail car une réinitialisation de mot de passe a ét
msgid "email.welcome.reason"
msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse."
#: src/contexts/ws/WSProvider.tsx:915
#: src/contexts/ws/WSProvider.tsx:920
msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé"

View File

@@ -10,11 +10,15 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/fraunces": "5.3.0",
"@fontsource-variable/bricolage-grotesque": "5.3.0",
"@fontsource-variable/jetbrains-mono": "5.3.0",
"@fontsource-variable/saira": "5.3.0",
"@fontsource-variable/space-grotesk": "5.3.0",
"@fontsource-variable/libre-franklin": "5.3.0",
"@fontsource-variable/newsreader": "5.3.0",
"@fontsource/comic-neue": "5.3.0",
"@fontsource/instrument-serif": "5.3.0",
"@fontsource/manufacturing-consent": "5.3.0",
"@lingui/cli": "6.8.0",
"@lingui/conf": "6.8.0",
"@lingui/core": "6.8.0",

View File

@@ -1,14 +1,16 @@
/**
* What the whole site did over a period, one of the digests' cadences: its
* most upvoted dumps, who joined, the new public playlists and the totals.
* This module types it for the `/community` page, which shows it as it was at
* the server's last refresh; the digests gather the same figures for each
* reader's own period.
* What the whole site did, told two ways. Over a period, one of the digests'
* cadences: its most upvoted dumps, who joined, the new public playlists and
* the totals, which the `/community` page shows as they were at the server's
* last refresh and the digests gather for each reader's own period. And as it
* happens: the community feed, the latest things its members did in public,
* which the GeoCities style's marquee scrolls.
*
* @module
*/
import type { Dump } from "./dump.ts";
import type { OwnedItem } from "./paths.ts";
import type { Playlist } from "./playlist.ts";
import type { PublicUser } from "./user.ts";
@@ -30,3 +32,86 @@ export interface CommunityStats {
/** Public dumps posted, upvotes cast and comments written in it. */
totals: { dumps: number; upvotes: number; comments: number };
}
/**
* Names a dump or a playlist in a {@link CommunityEvent}: its title, and what
* `dumpPath` or `playlistPath` builds its page's path from.
*/
export interface CommunityItem extends OwnedItem {
title: string;
}
/** What every {@link CommunityEvent} carries. */
interface CommunityEventBase {
/**
* The event's kind and what it is about, `dump_upvoted:<dump id>`: unique in
* a feed, where a dump has one event of each kind at most.
*/
id: string;
at: Date;
/** Who did it, by their current name. */
username: string;
}
/** A public dump was posted. A dump made public later keeps its date. */
export interface DumpPostedEvent extends CommunityEventBase {
type: "dump_posted";
dump: CommunityItem;
}
/** A public playlist was created. */
export interface PlaylistCreatedEvent extends CommunityEventBase {
type: "playlist_created";
playlist: CommunityItem;
}
/**
* A public dump got a comment. However many it got, the feed tells of its
* latest alone.
*/
export interface DumpCommentedEvent extends CommunityEventBase {
type: "dump_commented";
dump: CommunityItem;
/** For the `#comment-<id>` anchor on the dump's page. */
commentId: string;
}
/** A public dump got an upvote, told as {@link DumpCommentedEvent} is. */
export interface DumpUpvotedEvent extends CommunityEventBase {
type: "dump_upvoted";
dump: CommunityItem;
}
/** Someone joined the site: `username` is theirs. */
export interface UserJoinedEvent extends CommunityEventBase {
type: "user_joined";
invitedByUsername?: string;
}
/** One line of the community feed, told apart by `type`. */
export type CommunityEvent =
| DumpPostedEvent
| PlaylistCreatedEvent
| DumpCommentedEvent
| DumpUpvotedEvent
| UserJoinedEvent;
/**
* Returned by `GET /api/community/feed` to `CommunityFeedProvider`, which
* asks again as the socket tells of changes, and rendered by the
* `CommunityFeed` component with who is online, which the client knows from
* its socket. `community-service.ts` gathers it. Everything in it is public:
* every reader gets the same feed.
*/
export interface CommunityFeed {
/** The latest events, newest first, 12 at most. */
events: CommunityEvent[];
/**
* The last week's figures, as `/community` shows them: as of the server's
* last refresh.
*/
week: Pick<CommunityStats, "totals" | "chatMessages"> & {
/** The week's most upvoted dump, when one was upvoted. */
top?: CommunityItem;
};
}

42
shared/image.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Names the widths the site's own images come in. Every image the API serves
* (avatars, covers, thumbnails, attachments, proxied images) takes `?w=` and
* answers a copy scaled down to it, so a card or an avatar doesn't load the
* upload at full size. The frontend builds those URLs from this list
* (`imageSrcSet` in `src/utils/shared/urls.ts`) and the server makes no other
* width (`serveImage` in `api/lib/upload.ts`), which bounds what it keeps.
*
* @module
*/
/**
* The widths, in pixels, smallest first: an avatar on a dense screen, a small
* cover, a card on a phone, and the widest feed card on a dense screen.
*/
export const IMAGE_WIDTHS = [128, 320, 640, 960] as const;
/** One of {@link IMAGE_WIDTHS}. */
export type ImageWidth = typeof IMAGE_WIDTHS[number];
/** The largest of {@link IMAGE_WIDTHS}, which a thumbnail never exceeds. */
export const MAX_IMAGE_WIDTH: ImageWidth =
IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
/**
* Returns the narrowest of {@link IMAGE_WIDTHS} that covers `width` pixels, or
* the widest when none does.
*/
export function snapImageWidth(width: number): ImageWidth {
return IMAGE_WIDTHS.find((w) => w >= width) ?? MAX_IMAGE_WIDTH;
}
/**
* Returns the width a `?w=` value asks for, snapped to {@link IMAGE_WIDTHS},
* or null when it is absent or not a positive number, which asks for the image
* as it is.
*/
export function parseImageWidth(raw: string | null): ImageWidth | null {
if (raw === null || !/^\d{1,5}$/.test(raw)) return null;
const width = Number(raw);
return width > 0 ? snapImageWidth(width) : null;
}

View File

@@ -1,7 +1,8 @@
/**
* Flattens the Markdown users write into one line of plain text, where a
* quote of it can't render markup: the chat's reply previews and the digest
* email's quotes of a comment or a note.
* quote of it can't render markup: the chat's reply previews, the digest
* email's quotes of a comment or a note, and the line of a description on a
* playlist's card and on a user in the search results.
*
* @module
*/

View File

@@ -6,7 +6,9 @@
* categories. Under a user each segment is a fixed tab name, and under `dumps`
* or `playlists` a slug. A view of a page is one more segment, and a default
* 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.
* 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
* 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`
* wraps the dump and playlist ones), the emails prefix them with the
@@ -19,6 +21,11 @@
*/
import type { CategoryKind } from "./category.ts";
import {
parsePlaylistSort,
PLAYLIST_SORTS,
type PlaylistSort,
} from "./playlist.ts";
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
import { HANDLE_RE, splitHandle } from "./validation.ts";
@@ -64,6 +71,15 @@ export const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
/** One of the {@link SEARCH_TABS}. */
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
* `/community`, the month at `/community/monthly`.
@@ -142,10 +158,40 @@ export function feedPath(
/**
* Returns the path of the page of every public playlist, `/playlists`, or of a
* playlist category's at `/playlists/<slug>`.
* playlist category's at `/playlists/<slug>`, on the tab `tab` names: an
* order, the default one without it, or the viewer's own playlists.
*/
export function playlistsPath(categorySlug?: string): string {
return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
export function playlistsPath(
categorySlug?: string,
tab?: PlaylistsTab,
): string {
const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
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"));
}
/**
* Returns the query string that puts a list of playlists in the order `sort`
* names, with its `?`, or `""` for the default order. `extra` holds the
* list's other parameters, such as a profile's `category`.
*/
export function playlistSortQuery(
sort: PlaylistSort = PLAYLIST_SORTS[0],
extra?: URLSearchParams,
): string {
const params = new URLSearchParams(extra);
if (sort === PLAYLIST_SORTS[0]) params.delete("sort");
else params.set("sort", sort);
const query = params.toString();
return query ? `?${query}` : "";
}
/**

View File

@@ -8,6 +8,26 @@
import type { Dump } from "./dump.ts";
/**
* The orders a list of playlists comes in, the first being the default one:
* `updated`, by {@link playlistActivity}, the latest first; `new`, by
* creation, the newest first; `dumps`, the fullest first. `GET /api/playlists`
* and `GET /api/users/:key/playlists` take one as `?sort=`, the `/playlists`
* page and a profile's playlists tab show them as tabs and keep the one
* picked in their URL's `?sort=`.
*/
export const PLAYLIST_SORTS = ["updated", "new", "dumps"] as const;
/** One of the {@link PLAYLIST_SORTS}. */
export type PlaylistSort = (typeof PLAYLIST_SORTS)[number];
/**
* Returns the order a `?sort=` names, the default one when it is missing or
* names none: like a page number, an order never fails a list.
*/
export function parsePlaylistSort(value: string | null): PlaylistSort {
return PLAYLIST_SORTS.find((sort) => sort === value) ?? PLAYLIST_SORTS[0];
}
/**
* Returned by the playlist routes, and by the search, users and dumps routes
* that list playlists. The `playlist_created` and `playlist_updated` WebSocket
@@ -40,10 +60,16 @@ export interface Playlist {
/** It is undefined when the playlist has no cover. */
imageMime?: string;
/**
* It counts the dumps the viewer can't see too. It is undefined when the
* query didn't count them.
* It counts the dumps the viewer may see in it, as its page lists them. It
* is undefined when the query didn't count them.
*/
dumpCount?: number;
/**
* When the latest of the dumps the viewer may see was added to it, which
* {@link playlistActivity} reads. It is undefined when it holds none, or
* when the query didn't count them.
*/
lastDumpAt?: Date;
/** The owner's current name, which the playlist's path starts with. */
ownerUsername: string;
/**
@@ -53,6 +79,40 @@ export interface Playlist {
categoryIds?: string[];
}
/**
* Returns when `playlist` last took a dump, or was created if it holds none:
* what the `updated` order sorts by and the cards show. Its title, cover and
* description may change after without bringing it back up a list.
*/
export function playlistActivity(
playlist: Pick<Playlist, "createdAt" | "lastDumpAt">,
): Date {
return playlist.lastDumpAt ?? playlist.createdAt;
}
/**
* Returns the comparison that puts playlists in the order `sort` names, as
* the API pages them, the newer first on a tie. `usePlaylistListSync` finds
* with it where a playlist the socket announces goes in a list.
*/
export function comparePlaylists(
sort: PlaylistSort,
): (a: Playlist, b: Playlist) => number {
const byCreation = (a: Playlist, b: Playlist) =>
b.createdAt.getTime() - a.createdAt.getTime();
switch (sort) {
case "updated":
return (a, b) =>
playlistActivity(b).getTime() - playlistActivity(a).getTime() ||
byCreation(a, b);
case "dumps":
return (a, b) =>
(b.dumpCount ?? 0) - (a.dumpCount ?? 0) || byCreation(a, b);
case "new":
return byCreation;
}
}
/**
* Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and
* by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in

22
shared/test/image.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { assertEquals } from "@std/assert";
import { parseImageWidth, snapImageWidth } from "../image.ts";
Deno.test("snapImageWidth: the narrowest width that covers, the widest past them all", () => {
assertEquals(snapImageWidth(1), 128);
assertEquals(snapImageWidth(128), 128);
assertEquals(snapImageWidth(129), 320);
assertEquals(snapImageWidth(700), 960);
assertEquals(snapImageWidth(5000), 960);
});
Deno.test("parseImageWidth: a positive number, or no width at all", () => {
assertEquals(parseImageWidth("320"), 320);
assertEquals(parseImageWidth("200"), 320);
assertEquals(parseImageWidth(null), null);
assertEquals(parseImageWidth(""), null);
assertEquals(parseImageWidth("0"), null);
assertEquals(parseImageWidth("-64"), null);
assertEquals(parseImageWidth("12.5"), null);
assertEquals(parseImageWidth("wide"), null);
assertEquals(parseImageWidth("999999999"), null);
});

View File

@@ -3,7 +3,10 @@ import {
dumpPath,
feedPath,
parsePagePath,
parsePlaylistsTab,
playlistPath,
playlistSortQuery,
playlistsPath,
searchPath,
userPath,
} from "../paths.ts";
@@ -37,6 +40,27 @@ Deno.test("builders: one hierarchy, default views bare", () => {
assertEquals(searchPath(), "/search");
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
assertEquals(searchPath("dumps", "x"), "/search?q=x");
assertEquals(playlistsPath(), "/playlists");
assertEquals(playlistsPath("mixes"), "/playlists/mixes");
assertEquals(playlistsPath(undefined, "updated"), "/playlists");
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
// an old `sort` out, and leaves the rest.
const category = new URLSearchParams({ category: "mixes", sort: "new" });
assertEquals(
playlistSortQuery("dumps", category),
"?category=mixes&sort=dumps",
);
assertEquals(playlistSortQuery("updated", category), "?category=mixes");
assertEquals(playlistSortQuery("updated"), "");
});
Deno.test("parsePagePath: what a path under a user names", () => {

View File

@@ -281,6 +281,22 @@ export interface DumpDeletedMessage {
dumpId: string;
}
/**
* Sent by the server when the dumps related to some dumps changed: a note or
* a comment gained or lost a link to a dump, or a dump that has such links
* turned public. It goes to the sockets whose user may see both ends of a
* link. The `Dump` page showing one of `dumpIds` fetches its related dumps
* again.
*/
export interface DumpRelatedChangedMessage {
type: "dump_related_changed";
/**
* The dump whose links changed, then the dumps at their other end, without
* those this socket's user may not see.
*/
dumpIds: string[];
}
/**
* Sent by the server when a playlist is created, to every socket if it is
* public, else to its owner's. It also goes to everyone but the owner when a
@@ -295,7 +311,8 @@ export interface PlaylistCreatedMessage {
/**
* Sent by the server when a playlist changes, to every socket if it is public,
* else to its owner's, as when it has just gone private.
* else to its owner's, as when it has just gone private. Its `dumpCount` and
* `lastDumpAt` are the socket's user's, as in `playlist_created`.
*/
export interface PlaylistUpdatedMessage {
type: "playlist_updated";
@@ -325,8 +342,11 @@ export interface PlaylistDumpsUpdatedMessage {
* private dump is left out.
*/
dumpIds: string[];
/** Counts every dump, hidden ones included, as `Playlist.dumpCount` does. */
dumpCount: number;
/**
* When the latest of them was added, the playlist's `lastDumpAt` for this
* socket's user. It is missing when `dumpIds` is empty.
*/
lastDumpAt?: Date;
}
/**
@@ -488,6 +508,7 @@ export type ServerToClientMessage =
| DumpCreatedMessage
| DumpUpdatedMessage
| DumpDeletedMessage
| DumpRelatedChangedMessage
| PlaylistCreatedMessage
| PlaylistUpdatedMessage
| PlaylistDeletedMessage

View File

@@ -223,67 +223,151 @@
word-break: break-word;
}
.dump-title-view {
/* ── Inline edit ── */
/* Text edited where it stands (InlineEdit.tsx). Hovered, the text sits on a
faint plate with a ✎ after it; opened, the field takes the plate's place
and the text's type, so that nothing jumps but the buttons that come under
it. The plate spills past the text, drawn under it by a pseudo-element: the
text's own box keeps its size, which a row measuring it (a playlist's title
beside its buttons) depends on. */
.inline-edit {
/* Tinted from the text, as it sits on a card or on the page alike. */
--inline-edit-plate: color-mix(in srgb, var(--color-text) 7%, transparent);
--inline-edit-pad-y: 0.1rem;
--inline-edit-pad-x: 0.45rem;
min-width: 0;
max-width: 100%;
}
.inline-edit--block {
--inline-edit-pad-y: 0.45rem;
--inline-edit-pad-x: 0.6rem;
}
.inline-edit-view {
position: relative;
isolation: isolate;
max-width: 100%;
cursor: text;
}
.inline-edit-view::before {
content: "";
position: absolute;
inset: calc(-1 * var(--inline-edit-pad-y))
calc(-1 * var(--inline-edit-pad-x));
z-index: -1;
border-radius: var(--radius-md);
transition: background-color var(--dur-fast);
}
.inline-edit--line .inline-edit-view {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: var(--radius-md);
max-width: 100%;
/* Padding gives the hover box breathing room (incl. right of the pencil);
the matching negative margin keeps the title's visual position unchanged. */
padding: 0.1rem 0.45rem;
margin: -0.1rem -0.45rem;
gap: 0.4em;
}
.dump-title-view--editable {
cursor: pointer;
/* Room on the right for the ✎, which sits in the corner. */
.inline-edit--block .inline-edit-view {
display: block;
padding-right: 1.5rem;
}
.dump-title-view--editable:hover {
background: var(--color-surface);
.inline-edit-view:hover::before {
background: var(--inline-edit-plate);
}
.dump-title-edit-btn {
font-size: var(--text-sm);
line-height: 1;
.inline-edit-mark {
flex-shrink: 0;
padding: 0.15em;
border: none;
border-radius: var(--radius-sm);
background: none;
font-family: inherit;
font-size: var(--text-sm);
font-weight: 400;
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
opacity: 0;
transition: opacity var(--dur-fast);
pointer-events: none;
}
.dump-title-view--editable:hover .dump-title-edit-btn,
.dump-title-view--editable:focus-visible .dump-title-edit-btn {
.inline-edit--block .inline-edit-mark {
position: absolute;
top: 0;
right: 0;
}
.inline-edit-view:hover .inline-edit-mark,
.inline-edit-mark:focus-visible {
opacity: 1;
}
.dump-title-editor {
/* No pointer to hover with: the mark stays, faintly, to say it can be
tapped. */
@media (hover: none) {
.inline-edit-mark {
opacity: 0.45;
}
}
/* What an empty text says to the one who may fill it. */
.inline-edit-empty {
margin: 0;
color: var(--color-text-muted);
font-style: italic;
}
.inline-edit-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
/* Typed as the text it replaces: the type is inherited at no specificity, so
that the class of that text (`fieldClassName`) and a style's rules for it
both set it over this. */
:where(.inline-edit-input) {
font: inherit;
letter-spacing: inherit;
color: inherit;
}
.inline-edit-input {
box-sizing: border-box;
width: calc(100% + 2 * var(--inline-edit-pad-x));
min-width: 0;
padding: calc(var(--inline-edit-pad-y) - var(--border-width))
calc(var(--inline-edit-pad-x) - var(--border-width));
margin: calc(-1 * var(--inline-edit-pad-y))
calc(-1 * var(--inline-edit-pad-x));
border: var(--border-width) solid transparent;
border-radius: var(--radius-md);
background: var(--inline-edit-plate);
transition: border-color var(--dur-base), box-shadow var(--dur-base);
}
/* As the Markdown editor's box takes the focus. */
.inline-edit-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.inline-edit-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
}
/* Shared chrome for inline-edit inputs (the editors that live outside `.form`).
Each rule below adds only its own size / weight / width deltas. */
.dump-title-input,
.profile-email-input,
.username-field-input {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
outline: none;
transition: border-color var(--dur-slow);
}
.dump-title-input:focus,
.profile-email-input:focus,
.username-field-input:focus {
border-color: var(--color-accent);
.inline-edit-error {
margin: 0;
font-size: var(--text-sm);
color: var(--color-danger);
}
/* A name and its "Rename" button, the hint and errors below. */
@@ -304,28 +388,18 @@
flex: 1 1 10rem;
min-width: 0;
padding: 0.4rem 0.6rem;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
font-size: var(--text-base);
outline: none;
transition: border-color var(--dur-slow);
}
.dump-title-input {
flex: 1;
min-width: 12rem;
padding: 0.4rem 0.6rem;
font-size: var(--text-xl);
font-weight: 700;
}
/* Save and Cancel wrap together, under the input on a phone. */
.dump-title-actions {
display: flex;
gap: 0.5rem;
}
.dump-title-error {
width: 100%;
margin: 0;
font-size: var(--text-sm);
color: var(--color-danger);
.username-field-input:focus {
border-color: var(--color-accent);
}
/* On the narrowest viewports the fixed-width action column squeezes the title
@@ -2289,6 +2363,14 @@ body.has-player .chat-fab {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
/* A list's order tabs, as the playlists', take a line of their own under the
title where the row has no room for both, rather than scroll beside it. */
.profile-section-header:has(.playlist-sort-tabs) {
flex-wrap: wrap;
row-gap: 0.25rem;
}
/* ── Profile (own) page ── */
@@ -2349,23 +2431,9 @@ body.has-player .chat-fab {
/* No margins of its own: the page's gap already sets it apart. */
.profile-description {
width: 100%;
}
.profile-description-view {
position: relative;
padding: 0.5rem 0.6rem;
border-radius: var(--radius-md);
font-size: var(--text-md);
}
.profile-description-view--editable {
cursor: pointer;
}
.profile-description-view--editable:hover {
background: var(--color-surface);
}
.profile-description-text {
white-space: pre-wrap;
overflow-wrap: break-word;
@@ -2373,80 +2441,16 @@ body.has-player .chat-fab {
line-height: 1.6;
}
.profile-description-empty {
color: var(--color-muted);
font-style: italic;
}
.profile-description-edit-btn {
position: absolute;
top: 0.55rem;
right: 0.5rem;
font-size: var(--text-sm);
color: var(--color-muted);
opacity: 0;
transition: opacity var(--dur-fast);
pointer-events: none;
}
.profile-description-view--editable:hover .profile-description-edit-btn,
.profile-description-view--editable:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-description-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profile-email-display {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 0.35rem;
.profile-email {
margin: 0.1rem 0 0.4rem;
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0.1rem 0 0.4rem;
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0.1rem 0.2rem;
margin-left: -0.2rem;
}
.profile-email-display:hover {
background: var(--color-surface);
}
.profile-email-display:hover .profile-description-edit-btn,
.profile-email-display:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-email-editor {
margin: 0.2rem 0 0.4rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.profile-email-input {
width: min(260px, 100%);
padding: 0.35rem 0.6rem;
border-radius: var(--radius-md);
/* An address is typed a size up from the line it shows on. */
.profile-email .inline-edit-input {
max-width: 22rem;
font-size: var(--text-sm);
line-height: 1.5;
}
.profile-email-actions {
display: flex;
gap: 0.4rem;
}
.profile-description-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.profile-invited-by {
@@ -2723,7 +2727,11 @@ body.has-player .chat-fab {
display: flex;
align-items: center;
gap: 0.5rem;
overflow-wrap: anywhere;
/* A word stays whole unless it alone is wider than the line. Breaking
anywhere lets the text be squeezed under the width of a word, and Firefox
sizes a title set with negative letter-spacing a hair narrower than it
lays it out, which dropped the last letter to a line of its own. */
overflow-wrap: break-word;
}
.page-title-icon {
@@ -2761,11 +2769,15 @@ body.has-player .chat-fab {
padding: 1rem;
}
/* A list of dump or playlist cards in a group, or right under a page's header
as on /playlists, fills the column, its edges in line with the label's rule
or the header. */
.page-group .dump-feed,
.page-content--narrow > .dump-feed {
/* A card that holds one line of text, such as a list with nothing in it yet:
the card's padding spaces it, not the paragraph's margins as well. */
.page-card > p:only-child {
margin: 0;
}
/* A list of dump or playlist cards in a group fills the column, its edges in
line with the label's rule. */
.page-group .dump-feed {
padding: 0;
max-width: none;
}
@@ -2849,6 +2861,13 @@ body.has-player .chat-fab {
flex-shrink: 0;
color: var(--color-text);
text-decoration: none;
/* It answers the pointer as a card's title does (.dump-card-title): the
accent, eased in. A theme that colors the name restates the hover. */
transition: color var(--dur-base);
}
.app-header-brand:hover {
color: var(--color-accent);
}
.app-header-center {
@@ -2923,7 +2942,7 @@ body.has-player .chat-fab {
/* When the search bar is expanded, immediately clear the rest of the center —
the expanded input needs the full column width */
.app-header-center:has(.search-bar--expanded) .index-presence,
.app-header-center:has(.search-bar--expanded) .feed-sort {
.app-header-center:has(.search-bar--expanded) .feed-sort-scroller {
display: none;
}
@@ -2936,10 +2955,6 @@ body.has-player .chat-fab {
min-width: 0;
}
.app-header-brand:hover {
color: var(--color-accent);
}
.app-header-nav {
display: flex;
align-items: center;
@@ -3109,16 +3124,39 @@ body.has-player .chat-fab {
--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 {
display: none;
display: flex;
align-items: center;
gap: 0.6rem;
}
@media (min-width: 1150px) {
.nav-links {
display: flex;
@media (max-width: 559px), (min-width: 860px) and (max-width: 1149px) {
.app-header:not(.app-header--guest) .nav-links {
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;
}
}
@@ -3763,90 +3801,54 @@ body.has-player .chat-fab {
/* ── Feed sort scroller + buttons ── */
/* Wrapper: constrains width, clips scroll overflow, and carries the gradient
hint. --tab-row-bg is what the row sits on, which the fade and the pinned
leading content paint so the tabs vanish under them. */
/* Wrapper: the leading content (the scope switcher), then the row of tabs,
which scrolls on its own beside it. Nothing here paints the ground the row
sits on: a theme's page may be a gradient or a picture, which a patch of
flat color would show against. */
.feed-sort-scroller {
position: relative;
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
overflow: hidden;
--tab-row-bg: var(--color-bg);
}
.app-header .feed-sort-scroller {
--tab-row-bg: var(--header-bg);
}
/* Right-edge fade, over the scroll container whatever its scroll position.
It shows only while tabs are hidden past that edge (TabBar's
--more), so it never dims the last tab once it is scrolled to or fits. */
.feed-sort-scroller::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2rem;
background: linear-gradient(to right, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more::after {
opacity: 1;
}
/* Its twin on the left edge once the row has scrolled, unless leading content
is pinned there, which draws its own (.feed-sort-leading::after). */
.feed-sort-scroller::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 2rem;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more-left:not(:has(.feed-sort-leading))::before {
opacity: 1;
}
/* The leading content (the scope switcher) stays at the row's start while the
tabs scroll under it. Its padding hides them right after its divider, and
the matching negative margin keeps the gap to the first tab unchanged. */
.feed-sort-leading {
position: sticky;
left: 0;
z-index: 1;
display: flex;
flex-shrink: 0;
padding-right: 0.4rem;
margin-right: -0.4rem;
background: var(--tab-row-bg);
}
/* Once tabs have scrolled under it, a short fade softens their cut edge. */
.feed-sort-leading::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 100%;
width: 0.75rem;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
/* The row fades out at an edge while tabs are hidden past it (TabBar's
--more and --more-left), so a fade never dims a tab that is scrolled to or
fits. The fade is a mask, the tabs thinning out over whatever is behind
them. The two lengths are registered so that they can ease in. */
@property --tab-fade-start {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
.feed-sort-scroller--more-left .feed-sort-leading::after {
opacity: 1;
@property --tab-fade-end {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
.feed-sort-scroller {
transition: --tab-fade-start var(--dur-base), --tab-fade-end var(--dur-base);
}
.feed-sort-scroller--more {
--tab-fade-end: 2rem;
}
.feed-sort-scroller--more-left {
--tab-fade-start: 2rem;
}
/* Beside leading content the tabs are cut at its divider, and a shorter fade
softens the cut. */
.feed-sort-scroller--more-left:has(.feed-sort-leading) {
--tab-fade-start: 0.75rem;
}
/* In the index below-header the scroller should fill the available row width */
@@ -3860,6 +3862,13 @@ body.has-player .chat-fab {
gap: 0.4rem;
overflow-x: auto;
scrollbar-width: none;
mask-image: linear-gradient(
to right,
transparent,
#000 var(--tab-fade-start),
#000 calc(100% - var(--tab-fade-end)),
transparent
);
/* Symmetric, so the tabs stay centred against their row (the header's
brand and buttons), with room on both sides for the focus ring that
overflow-x would otherwise clip. */
@@ -4064,9 +4073,8 @@ body.has-player .chat-fab {
0.25);
}
/* ── Shared card preview icon ── */
.dump-card-preview-icon,
.playlist-card-icon {
/* ── Dump card preview icon ── */
.dump-card-preview-icon {
font-size: var(--text-3xl);
opacity: 0.7;
line-height: 1;
@@ -4355,6 +4363,82 @@ body.has-player .chat-fab {
}
}
/* ── Playlist grid ──
The playlists page shows its cards as tiles, the cover across the tile's
top and the text under it: the same card as in a .dump-feed, laid out by
the list it is in, so every theme's card skin carries over. */
.playlist-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: 1rem;
}
.playlist-grid > li {
animation: card-enter 0.2s ease both;
}
/* The tiles of a row are as tall as the tallest, each one's text starting at
the same height under covers of one size. */
.playlist-grid .playlist-card {
display: flex;
}
.playlist-grid .playlist-card-inner {
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 0.65rem;
padding: 0.6rem 0.6rem 0.8rem;
}
.playlist-grid .playlist-card-preview {
width: 100%;
height: auto;
aspect-ratio: 1;
}
/* A cover this size fills the tile: grown as a thumb is, it would spill over
the card's edge. */
.playlist-grid .playlist-card-inner:hover .playlist-card-preview {
transform: none;
}
.playlist-grid .playlist-card-preview .thumb-placeholder-initials {
font-size: var(--text-display);
}
.playlist-grid .playlist-card-body {
margin-left: 0;
padding-inline: 0.15rem;
}
.playlist-grid .playlist-card-title,
.playlist-grid .playlist-card-description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
/* Owner, count and date wrap whole rather than run past a narrow tile. */
.playlist-grid .playlist-card-meta {
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 0.1rem 0.6rem;
}
@media (max-width: 512px) {
.playlist-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
}
.dump-card-vote {
flex-shrink: 0;
align-self: center;
@@ -5025,19 +5109,10 @@ body.has-player .chat-fab {
color: var(--color-danger);
}
.chat-edit {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.chat-edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
/* The input rests on one line, as tall as the Send button beside it: both
take --chat-composer-height, which any style's line of text fits in. */
.chat-composer {
--chat-composer-height: 2.75rem;
display: flex;
gap: 0.5rem;
align-items: flex-end;
@@ -5066,6 +5141,10 @@ body.has-player .chat-fab {
above the input, in the gap over the composer: inside the textarea, the text
ran under them, and in a row below they would knock the Send button out of
alignment. */
.chat-composer .chat-input {
min-height: var(--chat-composer-height);
}
.chat-composer .text-editor-footer {
position: absolute;
left: 0.25rem;
@@ -5080,9 +5159,9 @@ body.has-player .chat-fab {
.chat-send-btn {
flex-shrink: 0;
/* Match the input's resting height so the two line up exactly (both edges),
while the input is free to grow taller as you type. */
height: 2.4rem;
/* The input's resting height, so that the two line up at both edges, while
the input is free to grow taller as you type. */
height: var(--chat-composer-height);
box-sizing: border-box;
}
@@ -5430,6 +5509,23 @@ body.has-player .chat-fab {
flex-wrap: wrap;
}
/* The open title field takes the room its buttons leave beside it, which
line up with its top rather than with its Save and Cancel. */
.playlist-detail-title-row:has(> .inline-edit--editing) {
align-items: flex-start;
}
.playlist-detail-title-row > .inline-edit--editing {
flex: 1 1 16rem;
/* Clear of the buttons by the gap, past the field's spill. */
margin-right: var(--inline-edit-pad-x);
}
/* The title on the left, Follow and Edit together at the row's end. */
.playlist-detail-title-row > :first-child:not(.inline-edit--editing) {
margin-right: auto;
}
.playlist-detail-title {
margin: 0;
font-size: var(--text-2xl);
@@ -5723,7 +5819,7 @@ body.has-player .chat-fab {
align-self: center;
}
.collections-section .playlist-card-icon {
.collections-section .playlist-card-preview .thumb-placeholder-initials {
font-size: var(--text-lg);
}
@@ -7439,7 +7535,7 @@ body.has-player .chat-fab {
}
/* Vertical rule separating the scope selector from the sort tabs. */
.feed-sort .category-switcher::after {
.feed-sort-leading .category-switcher::after {
content: "";
width: 2px;
height: 1.05rem;
@@ -7532,7 +7628,7 @@ body.has-player .chat-fab {
.category-link {
padding: 0.1rem 0.5rem;
border-radius: var(--radius-full);
background: var(--color-header-user-bg);
background: var(--color-badge-bg);
color: var(--color-text-secondary);
text-decoration: none;
font-size: var(--text-xs);
@@ -7718,6 +7814,9 @@ body.has-player .chat-fab {
.community-figure {
display: flex;
flex-direction: column-reverse;
/* In column-reverse, flex-end is the top: the figures stay on one line
when a label wraps to two. */
justify-content: flex-end;
align-items: center;
gap: 0.2rem;
min-width: 0;
@@ -7742,3 +7841,12 @@ body.has-player .chat-fab {
grid-template-columns: repeat(2, 1fr);
}
}
/* ── Community feed ── */
/* A bare list: the style that shows the feed lays it out, as GeoCities does
along its marquee. */
.community-feed {
list-style: none;
margin: 0;
padding: 0;
}

View File

@@ -23,7 +23,8 @@
* - `/community` and `/community/monthly`, the site's week and month
* (`Community`), signed in only.
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
* playlist category's (`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,
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
* signed in only), and their playlists at `playlists/:slug`
@@ -61,6 +62,7 @@ import { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx";
import { WSProvider } from "./contexts/ws/WSProvider.tsx";
import { FollowProvider } from "./contexts/social/FollowProvider.tsx";
import { CommunityFeedProvider } from "./contexts/social/CommunityFeedProvider.tsx";
import { CategoriesProvider } from "./contexts/category/CategoriesProvider.tsx";
import { ThemeProvider } from "./contexts/theme/ThemeProvider.tsx";
import { ChatProvider } from "./contexts/chat/ChatProvider.tsx";
@@ -202,15 +204,22 @@ function HandleRoutes() {
: <Feed categorySlug={handle} />;
}
/** Renders the route table, and the floating buttons over every page. */
function AppRoutes() {
/**
* Renders the route table, and the floating buttons over every page. Exported
* for the tests, which reach a page through it as a visit does.
*/
export function AppRoutes(): React.JSX.Element {
return (
<>
<Suspense>
<Routes>
<Route path="/" element={<Feed />} />
{
/* The splat takes the `follow` tab's view, which `Feed` reads from
the path; without it `/follow/playlists` is category `follow`. */
}
{FEED_TABS.map((tab) => (
<Route key={tab} path={`/${tab}`} element={<Feed />} />
<Route key={tab} path={`/${tab}/*`} element={<Feed />} />
))}
<Route path="/search" element={<Search />} />
<Route path="/search/:searchTab" element={<Search />} />
@@ -297,14 +306,16 @@ export function App(): React.JSX.Element {
<PlayerProvider>
<WSProvider>
<FollowProvider>
<CategoriesProvider>
<BrowserRouter>
<ChatProvider>
<AppRoutes />
<GlobalPlayer />
</ChatProvider>
</BrowserRouter>
</CategoriesProvider>
<CommunityFeedProvider>
<CategoriesProvider>
<BrowserRouter>
<ChatProvider>
<AppRoutes />
<GlobalPlayer />
</ChatProvider>
</BrowserRouter>
</CategoriesProvider>
</CommunityFeedProvider>
</FollowProvider>
</WSProvider>
</PlayerProvider>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,9 @@ import {
dumpFileUrl,
dumpThumbnailUrl,
dumpWaveformUrl,
imageProps,
} from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import {
fileArtworkUrl,
@@ -72,6 +74,7 @@ function FilePlayButton({ dump }: { dump: Dump }) {
<Thumbnail
src={fileArtworkUrl(dump, token)}
className="rich-content-compact-thumbnail"
sizes={IMAGE_SIZES.compactThumbnail}
placeholder={{ seed: dump.fileName ?? dump.id, glyph: mimeIcon(mime) }}
placeholderClassName="rich-content-compact-thumbnail"
/>
@@ -141,7 +144,10 @@ export function FilePreview(
// A card needs the server's scaled-down copy, not the full upload.
return (
<img
src={dumpThumbnailUrl(dump, token)}
{...imageProps(
dumpThumbnailUrl(dump, token),
IMAGE_SIZES.compactThumbnail,
)}
alt={dump.fileName}
className="rich-content-compact-thumbnail"
loading="lazy"
@@ -158,6 +164,7 @@ export function FilePreview(
<Thumbnail
src={artworkUrl}
className="rich-content-compact-thumbnail"
sizes={IMAGE_SIZES.compactThumbnail}
placeholder={{
seed: dump.fileName ?? dump.id,
glyph: mimeIcon(mime),

View File

@@ -10,6 +10,7 @@ import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../../../shared/dump.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { dumpUrl } from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
import {
@@ -195,6 +196,7 @@ export const JournalCard: React.MemoExoticComponent<
<div className="journal-card-image">
<Thumbnail
src={thumbnailUrl}
sizes={IMAGE_SIZES.journal[shape]}
placeholder={dump.kind === "file"
// A video still fails without ffmpeg on the server, and the MIME
// glyph says more than an initial taken from the file name.

View File

@@ -14,6 +14,7 @@ import {
usePlayRichContent,
} from "../../hooks/player/usePlayRichContent.ts";
import { Thumbnail } from "../shared/Thumbnail.tsx";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
/**
* Passed to {@link RichContentCard} by `DumpCard` (`compact`), `Dump`,
@@ -68,6 +69,7 @@ export function RichContentCard(
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
sizes={IMAGE_SIZES.compactThumbnail}
placeholder={placeholder}
placeholderClassName="rich-content-compact-thumbnail"
/>
@@ -135,6 +137,7 @@ export function RichContentCard(
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
sizes={IMAGE_SIZES.richThumbnail}
placeholder={placeholder}
placeholderClassName="rich-content-thumbnail"
/>

View File

@@ -0,0 +1,340 @@
/**
* Renders the editing of a text in place, with no page or form of its own.
* {@link InlineEdit} shows the text and opens the field over it: a dump's
* title and note, a playlist's title and description, a profile's description
* and email. {@link InlineEditField} is that field alone, for a text that
* opens it with an action of its own: a comment's Edit, a chat message's ✎.
*
* @module
*/
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider, useFormState } from "react-hook-form";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
/**
* Passed to {@link InlineEdit} by the component showing the text, which
* renders it as `children` and saves it in `onSave`.
*/
export interface InlineEditProps {
/** The text as saved, which the field opens on. */
value: string;
/** Without it, `children` show alone. */
editable: boolean;
/** Names the action, such as "Edit title": the text's tooltip. */
label: string;
/**
* Saves the trimmed text, and shows it: the field closes once it resolves.
* A thrown error, such as `expectOk`'s, stays open under the field. A text
* left as it was closes the field without calling it.
*/
onSave: (value: string) => Promise<void>;
/**
* A one-line field (`text`, `email`), where Enter saves, or the Markdown
* editor (`markdown`), where Ctrl/Cmd+Enter does. Escape cancels in both.
*/
kind?: "text" | "email" | "markdown";
/** Refuses to save an empty text. */
required?: boolean;
maxLength?: number;
placeholder?: string;
/** Added to the wrapper, which the text and the field both sit in. */
className?: string;
/** Added to the field: see {@link InlineEditFieldProps}. */
fieldClassName?: string;
children: ReactNode;
}
/**
* Renders `children`, which a click on them, or the ✎ button after them,
* swaps for a field holding `value`, with Save and Cancel under it. The ✎
* shows on hover and on keyboard focus. A click on a link in the text, or one
* that ends a selection, follows the link or keeps the selection instead.
* Closing the field gives the focus back to the ✎ when the field had it.
*/
export function InlineEdit(
{
value,
editable,
label,
onSave,
kind = "text",
required = false,
maxLength,
placeholder,
className,
fieldClassName,
children,
}: InlineEditProps,
): React.JSX.Element {
const [editing, setEditing] = useState(false);
const rootRef = useHeightTransition<HTMLDivElement>(editing);
const markRef = useRef<HTMLButtonElement>(null);
if (!editable) return <>{children}</>;
return (
<div
ref={rootRef}
className={`inline-edit inline-edit--${
kind === "markdown" ? "block" : "line"
}${editing ? " inline-edit--editing" : ""}${
className ? ` ${className}` : ""
}`}
>
{editing
? (
<InlineEditField
value={value}
onSave={onSave}
onClose={() => setEditing(false)}
returnFocusTo={markRef}
kind={kind}
required={required}
maxLength={maxLength}
placeholder={placeholder}
fieldClassName={fieldClassName}
label={label}
/>
)
: (
<div
className="inline-edit-view"
title={label}
// The text opens the field to the pointer; the keyboard has the ✎.
// Neither is a button around the text, which keeps its own role:
// a heading, Markdown with its links.
onClick={(e) => {
if ((e.target as Element).closest("a")) return;
if (getSelection()?.type === "Range") return;
setEditing(true);
}}
>
{children}
<button
ref={markRef}
type="button"
className="inline-edit-mark"
aria-label={label}
// Opens the field whatever is selected on the page.
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
>
✎
</button>
</div>
)}
</div>
);
}
/**
* Passed to {@link InlineEditField} by {@link InlineEdit}, and by the
* components that open it with an action of their own, `CommentNode` and
* `ChatMessageItem`, in place of the text while it is edited.
*/
export interface InlineEditFieldProps extends
Pick<
InlineEditProps,
"value" | "kind" | "required" | "maxLength" | "placeholder"
> {
/**
* Saves the trimmed text. The field closes once it is done, and stays open
* under the error it throws. A text left as it was isn't passed to it.
*/
onSave: (value: string) => Promise<void> | void;
/** Called once the text is saved, or left: the field is to unmount. */
onClose: () => void;
/** Names a one-line field, such as "Edit title". */
label?: string;
/**
* Added to the field: for a one-line one, the class of the text it edits,
* such as `dump-title`, so that it takes on its type in every style.
*/
fieldClassName?: string;
/**
* In the Markdown editor, saves on Enter, a line breaking on Shift+Enter, as
* the chat's composer does. Ctrl/Cmd+Enter saves in either case.
*/
enterSaves?: boolean;
/** Where the Markdown editor's counter starts, near `maxLength`. */
countFrom?: number;
/**
* Takes the focus once the field is gone, if it had it and no one has
* taken it since: the button that opened it, back in its place.
*/
returnFocusTo?: React.RefObject<HTMLElement | null>;
}
/**
* Renders the field holding `value`, focused with the caret at its end, and
* Save and Cancel under it. Enter saves a line, Ctrl/Cmd+Enter a Markdown
* text, Escape leaves. Saving is refused while the text is empty and
* `required`, or longer than `maxLength`. It is mounted afresh on each
* opening, so that it starts from `value`.
*/
export function InlineEditField(
{
value,
onSave,
onClose,
kind = "text",
required,
maxLength,
placeholder,
fieldClassName,
label,
enterSaves,
countFrom,
returnFocusTo,
}: InlineEditFieldProps,
): React.JSX.Element {
const form = useApiForm<{ text: string }>({
defaultValues: { text: value },
});
const text = form.watch("text");
const submitting = form.formState.isSubmitting;
const editorRef = useRef<TextEditorHandle>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Runs once the field is gone and the button that opened it is back. The
// focus fell to the page with the field that had it, unless the caller has
// put it somewhere already.
useEffect(() => () => {
const active = document.activeElement;
if (!active || active === document.body) {
returnFocusTo?.current?.focus({ preventScroll: true });
}
}, [returnFocusTo]);
// The caret goes after the text, where an edit most often begins.
useEffect(() => {
editorRef.current?.focus({ atEnd: true, preventScroll: true });
const input = inputRef.current;
if (!input) return;
input.focus({ preventScroll: true });
// An email field has no selection to set.
if (input.type === "text") {
input.setSelectionRange(input.value.length, input.value.length);
}
}, []);
const tooLong = maxLength !== undefined && text.length > maxLength;
const blank = required && !text.trim();
const onSubmit = form.submit(async ({ text }) => {
const trimmed = text.trim();
if (trimmed !== value.trim()) await onSave(trimmed);
onClose();
});
const save = () => {
if (!tooLong && !blank) void onSubmit();
};
const cancel = () => {
if (!submitting) onClose();
};
const { ref: registerRef, ...registration } = form.register("text");
return (
<FormProvider {...form}>
<form className="inline-edit-form animate-in" onSubmit={onSubmit}>
{kind === "markdown"
? (
<Controller
name="text"
control={form.control}
render={({ field }) => (
<TextEditor
ref={editorRef}
className={`comment-reply-textarea${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
value={field.value}
onChange={field.onChange}
onSubmit={save}
onCancel={cancel}
onKeyDown={enterSaves
? (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
}
: undefined}
placeholder={placeholder}
// As tall as the text it opens on, growing as it is typed.
rows={1}
autoResize
maxLength={maxLength}
countFrom={countFrom}
disabled={submitting}
/>
)}
/>
)
: (
<input
{...registration}
ref={(el) => {
registerRef(el);
inputRef.current = el;
}}
type={kind}
className={`inline-edit-input${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
aria-label={label}
placeholder={placeholder}
maxLength={maxLength}
disabled={submitting}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
/>
)}
<div className="inline-edit-actions">
<SubmitButton
className="btn-primary btn-sm"
pendingLabel={<Trans>Saving…</Trans>}
disabled={tooLong || blank}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary btn-sm"
onClick={cancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<InlineEditError />
</div>
</form>
</FormProvider>
);
}
/**
* Renders why the save failed, as one line beside the buttons: an
* `ErrorCard` would outweigh the field it is about.
*/
function InlineEditError(): React.JSX.Element | null {
const { errors } = useFormState();
const message = errors.root?.message;
if (!message) return null;
return (
<p className="inline-edit-error" role="alert">
{message}
</p>
);
}

View File

@@ -15,9 +15,10 @@ import { NotificationBell } from "./NotificationBell.tsx";
import { ChatButton } from "./ChatButton.tsx";
import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx";
import { Marquee } from "./Marquee.tsx";
import { IconClose, IconSearch } from "./NavIcons.tsx";
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
import { searchPath, userPath } from "../../../shared/paths.ts";
import { playlistsPath, searchPath } from "../../../shared/paths.ts";
/** Loaded on first open, so its editor stays out of every page's bundle. */
const DumpCreateModal = lazy(() =>
@@ -62,9 +63,10 @@ export interface AppHeaderProps {
/**
* 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
* signed-in user's links and buttons, and a banner while live updates are down.
* It also hosts the dump creation dialog that its "+ Dump" button opens.
* results page and slides open under the header on small screens, the link to
* the playlists page, the signed-in user's menu and buttons or a guest's
* "Log in", and a banner while live updates are down. It also hosts the dump
* creation dialog that its "+ Dump" button opens.
*/
export function AppHeader(
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
@@ -142,7 +144,11 @@ export function AppHeader(
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">
{SITE_EMOJI}
<span className="app-header-brand-name">
@@ -158,23 +164,17 @@ export function AppHeader(
<nav className="app-header-nav">
{
/* The user's own links lead the row, so the icons after them run
unbroken to the "+": Playlists on wide screens, then the
avatar's menu, which leads to their profile, at every width. */
/* The links lead the row, so the icons after them run unbroken to
the "+": the playlists page, at every width for a guest and
where the row has room for a signed-in user, then the avatar's
menu, which leads there too, at every width. */
}
{user && (
<>
<div className="nav-links">
<Link
to={userPath(user.username, "playlists")}
className="app-header-user"
>
<Trans>Playlists</Trans>
</Link>
</div>
<UserMenu user={user} />
</>
)}
<div className="nav-links">
<Link to={playlistsPath()} className="app-header-user">
<Trans>Playlists</Trans>
</Link>
</div>
{user && <UserMenu user={user} />}
<button
ref={searchToggleRef}
type="button"
@@ -228,6 +228,8 @@ export function AppHeader(
</div>
</div>
<Marquee />
{wsStatus === "disconnected" && wsErrorMessage && (
<div className="app-header-status" role="alert">
<strong>

View File

@@ -0,0 +1,158 @@
/**
* Renders the GeoCities style's marquee: the community feed crawling across
* the page under the header, as a `<marquee>` did on a homepage of that era.
* `AppHeader` renders it on every page. It draws nothing in any other style,
* nor for a visitor, the feed being for signed-in users.
*
* @module
*/
import {
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts";
import type { OnlineUser } from "../../../shared/ws.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCommunityFeed } from "../../hooks/social/useCommunityFeed.ts";
import { useTheme } from "../../hooks/theme/useTheme.ts";
import { useWSPresence } from "../../hooks/ws/useWS.ts";
import { CommunityFeed } from "../social/CommunityFeed.tsx";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
function onMotionChange(callback: () => void): () => void {
const query = matchMedia(REDUCED_MOTION);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
// Where the crawl had got to, and what its loop showed, when the marquee was
// last on screen. They are kept out of the component, which every page mounts
// anew with its header: the next page's marquee carries on from that point of
// that loop, rather than from the start. A crawl that was running has gone on
// since; one paused under the pointer, which has just clicked a link in it,
// hasn't.
let lastPosition:
| { elapsed: number; at: number; running: boolean }
| undefined;
let lastLoop: Loop | null = null;
/** What one loop of the crawl shows, from its start to its end. */
interface Loop {
feed: CommunityFeedData;
online: OnlineUser[];
/** Counts the loops, and keys the track: a new loop is a new animation. */
turn: number;
}
/**
* Renders the feed twice, side by side: geocities.css slides the pair left by
* the width of one, so the second copy arrives where the first began and the
* loop has no seam. The second copy is hidden from assistive technology and
* from the Tab key.
*
* A loop shows what the feed held when it began. Text that changed mid-crawl
* would jump, the track's width being what the slide is measured by, so what
* came meanwhile waits for the next loop, which also brings the lines' times
* up to date. The crawl's speed is the style's: this sets `--marquee-length`,
* one copy's width in pixels, from which the stylesheet works out how long a
* loop lasts.
*
* For a reader who asked for reduced motion nothing moves or repeats: the
* line shows once, and follows the feed as it changes.
*/
function FeedMarquee(): React.JSX.Element {
const feed = useCommunityFeed();
const { onlineUsers: online } = useWSPresence();
const still = useSyncExternalStore(
onMotionChange,
() => matchMedia(REDUCED_MOTION).matches,
);
// A marquee with no feed yet is a first one, or follows a sign-out.
const [loop, setLoop] = useState<Loop | null>(() => feed && lastLoop);
if (!loop && feed) setLoop({ feed, online, turn: 0 });
useEffect(() => {
lastLoop = loop;
}, [loop]);
const shown = still ? feed && { feed, online } : loop;
const trackRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const track = trackRef.current;
const length = track?.firstElementChild?.getBoundingClientRect().width;
if (!track || !length) return;
track.style.setProperty("--marquee-length", String(Math.round(length)));
const last = lastPosition;
lastPosition = undefined;
const elapsed = !last
? 0
: last.elapsed + (last.running ? performance.now() - last.at : 0);
// A negative delay starts the animation that far in.
track.style.animationDelay = `${-elapsed / 1000}s`;
return () => {
// The track's own animation: the focus can have stopped it.
const animation = track.getAnimations()[0];
const timing = animation?.effect?.getComputedTiming();
if (!timing?.progress || typeof timing.duration !== "number") return;
lastPosition = {
elapsed: timing.progress * timing.duration,
at: performance.now(),
running: animation.playState === "running",
};
};
}, [loop?.turn, still]);
return (
<div
className="site-marquee"
// A link focused from the keyboard stops the crawl, and the browser
// scrolls the line to show it: the line goes back to its start once the
// focus has left.
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
e.currentTarget.scrollLeft = 0;
}
}}
>
{shown && (
<div
key={loop?.turn}
ref={trackRef}
className="site-marquee-track"
onAnimationIteration={(e) => {
// Not a child's animation ending a round of its own.
if (e.target !== e.currentTarget || !feed || !loop) return;
setLoop({ feed, online, turn: loop.turn + 1 });
}}
>
<CommunityFeed feed={shown.feed} online={shown.online} />
{!still && (
<div aria-hidden="true">
<CommunityFeed
feed={shown.feed}
online={shown.online}
tabIndex={-1}
/>
</div>
)}
</div>
)}
</div>
);
}
/**
* Renders the marquee for a signed-in user in the GeoCities style, an empty
* band until the feed is loaded, so that the page under it doesn't move when
* it comes.
*/
export function Marquee(): React.JSX.Element | null {
const { style } = useTheme();
const { user } = useAuth();
if (style !== "geocities" || !user) return null;
return <FeedMarquee />;
}

View File

@@ -17,14 +17,15 @@ export interface PageHeaderProps {
title: ReactNode;
/**
* It goes at the end of the row, such as the notifications' unread count or
* the playlists' category menu.
* the playlists' category menu and order tabs.
*/
children?: ReactNode;
}
/**
* Renders the page's h1 with its icon, then `children`, as the first block of
* a narrow `PageShell`; `PageGroup`s follow it.
* a `PageShell`: a narrow one's `PageGroup`s follow it, or the playlists
* page's grid.
*/
export function PageHeader(
{ icon, title, children }: PageHeaderProps,

View File

@@ -1,7 +1,8 @@
/**
* Renders the avatar menu that leads the header's icon row at every width: it
* holds the profile link, the playlists one (on small screens, the only one),
* the community page, the settings, the site's tools, and logging out.
* holds the profile link, the playlists page (the header's text link leads
* there too, at the widths that have room for it), the community page, the
* settings, the site's tools, and logging out.
*
* @module
*/
@@ -29,7 +30,7 @@ export interface UserMenuProps {
/**
* 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
* button. A click outside or Escape closes it.
*/
@@ -84,7 +85,7 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
@{user.username}
</Link>
<Link
to={userPath(user.username, "playlists")}
to={playlistsPath()}
className="user-menu-item"
role="menuitem"
onClick={() => setOpen(false)}
@@ -99,14 +100,6 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
>
{i18n._(DIGEST_MESSAGES.community)}
</Link>
<Link
to={playlistsPath()}
className="user-menu-item"
role="menuitem"
onClick={() => setOpen(false)}
>
<Trans>All playlists</Trans>
</Link>
<Link
to="/settings"
className="user-menu-item"

View File

@@ -17,6 +17,7 @@ import {
import { MediaPlayer } from "./MediaPlayer.tsx";
import { fmt } from "../../utils/player/duration.ts";
import { Thumbnail } from "../shared/Thumbnail.tsx";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
@@ -201,6 +202,7 @@ export function GlobalPlayer(): React.JSX.Element | null {
<Thumbnail
src={current.artworkUrl}
className="global-player-artwork"
sizes={IMAGE_SIZES.playerArtwork}
placeholder={{ siteName: current.subtitle }}
placeholderClassName="global-player-artwork"
loading="eager"

View File

@@ -5,21 +5,29 @@
* @module
*/
import { Link, useNavigate } from "react-router";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import type { Playlist } from "../../../shared/playlist.ts";
import { type Playlist, playlistActivity } from "../../../shared/playlist.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
import {
imageProps,
playlistImageUrl,
playlistUrl,
} from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCardLink } from "../../hooks/shared/useCardLink.ts";
import {
isPlaylistVisited,
isRecent,
markPlaylistVisited,
} from "../../utils/shared/visited.ts";
import { ThumbnailPlaceholder } from "../shared/ThumbnailPlaceholder.tsx";
import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx";
import { userPath } from "../../../shared/paths.ts";
import { markdownToPlainText } from "../../../shared/markdown.ts";
/** Passed to {@link PlaylistCard} by each list of playlists. */
export interface PlaylistCardProps {
@@ -37,46 +45,59 @@ export interface PlaylistCardProps {
* public badges on every card there would say nothing.
*/
privateBadgeOnly?: boolean;
/** Set for a tile of the playlists page's grid, whose cover is larger. */
grid?: boolean;
}
/**
* Renders a playlist as a card that opens the playlist when clicked, with the
* playlist categories it is filed under. Another user's recent playlist shows
* an unread dot until it is opened on this device. The card is an `<li>`, for
* a `<ul>`.
* Renders a playlist as a card that opens the playlist when clicked, in a new
* tab on a middle click (`useCardLink`), with its cover, or a tile of its own
* colour without one, the playlist categories it is filed under and when it
* last took a dump. Another user's recent playlist shows an unread dot until
* it is opened on this device. The card is an `<li>`, for a `<ul>`, whose
* class lays it out: a row in a `.dump-feed`, a tile in a `.playlist-grid`.
*/
export function PlaylistCard(
{ playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps,
{ playlist, onDelete, isOwner, privateBadgeOnly, grid }: PlaylistCardProps,
): React.JSX.Element {
const navigate = useNavigate();
const { mediaToken } = useAuth();
const { cardProps } = useCardLink(
playlistUrl(playlist),
() => markPlaylistVisited(playlist.id),
);
const unread = !isOwner && isRecent(playlist.createdAt) &&
!isPlaylistVisited(playlist.id);
const showBadge = !privateBadgeOnly || (!!isOwner && !playlist.isPublic);
function handleNavigate() {
markPlaylistVisited(playlist.id);
navigate(playlistUrl(playlist));
}
// The playlist's page renders the description's Markdown; the card has
// room for its text only, and none at all when it is a lone image.
const description = playlist.description &&
markdownToPlainText(playlist.description, "");
const activity = playlistActivity(playlist);
const ago = relativeTime(activity);
return (
<li className="playlist-card">
<div
className="playlist-card-inner"
onClick={handleNavigate}
>
<div className="playlist-card-inner" {...cardProps}>
<div className="playlist-card-preview">
{playlist.imageMime
? (
<img
src={playlistImageUrl(playlist, mediaToken)}
{...imageProps(
playlistImageUrl(playlist, mediaToken),
grid ? IMAGE_SIZES.playlistTile : IMAGE_SIZES.playlistThumb,
)}
alt=""
className="playlist-card-img"
loading="lazy"
decoding="async"
/>
)
: <span className="playlist-card-icon">📋</span>}
: (
<ThumbnailPlaceholder
seed={playlist.id}
siteName={playlist.title}
/>
)}
</div>
<div className="playlist-card-body">
<Link
@@ -91,8 +112,8 @@ export function PlaylistCard(
{unread && <span className="unread-dot" aria-hidden="true" />}
{playlist.title}
</Link>
{playlist.description && (
<p className="playlist-card-description">{playlist.description}</p>
{description && (
<p className="playlist-card-description">{description}</p>
)}
<div className="playlist-card-meta">
{showBadge && (
@@ -124,9 +145,11 @@ export function PlaylistCard(
/>
</span>
)}
<Tooltip text={playlist.createdAt.toLocaleString()}>
<time dateTime={playlist.createdAt.toISOString()}>
{relativeTime(playlist.createdAt)}
<Tooltip text={activity.toLocaleString()}>
<time dateTime={activity.toISOString()}>
{playlist.lastDumpAt
? <Trans>updated {ago}</Trans>
: <Trans>created {ago}</Trans>}
</time>
</Tooltip>
</div>

View File

@@ -15,18 +15,24 @@ export interface PlaylistCardListProps {
onDelete?: (playlist: Playlist) => void;
/** Passed to each {@link PlaylistCard}. */
privateBadgeOnly?: boolean;
/**
* Lays the cards out as tiles, cover first, several to a row, for a page
* that is a list of playlists and nothing else: the playlists page.
*/
grid?: boolean;
}
/**
* Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, a card being
* its owner's when the playlist is the signed-in user's.
* Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, one to a
* row, or as a `.playlist-grid` of them, a card being its owner's when the
* playlist is the signed-in user's.
*/
export function PlaylistCardList(
{ playlists, onDelete, privateBadgeOnly }: PlaylistCardListProps,
{ playlists, onDelete, privateBadgeOnly, grid }: PlaylistCardListProps,
): React.JSX.Element {
const { user } = useAuth();
return (
<ul className="dump-feed">
<ul className={grid ? "playlist-grid" : "dump-feed"}>
{playlists.map((p) => (
<PlaylistCard
key={p.id}
@@ -34,6 +40,7 @@ export function PlaylistCardList(
isOwner={!!user && user.id === p.userId}
onDelete={onDelete && (() => onDelete(p))}
privateBadgeOnly={privateBadgeOnly}
grid={grid}
/>
))}
</ul>

View File

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

View File

@@ -0,0 +1,50 @@
/**
* Renders the tabs that put a list of playlists in another order, on the
* playlists page and on a profile's playlists tab, and the playlists page's
* tab of the viewer's own.
*
* @module
*/
import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
import type { PlaylistsTab } from "../../../shared/paths.ts";
import { TabBar } from "../shared/TabBar.tsx";
/**
* Passed to {@link PlaylistSortTabs} by `ProfilePlaylistsTab`, whose tabs
* 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<T extends PlaylistsTab> {
/** The tabs offered, in order. */
tabs: readonly T[];
/** The active tab. */
sort: T;
onChange: (sort: T) => void;
/** Goes before the tabs, as the playlists page's category menu does. */
leading?: ReactNode;
}
const LABELS: Record<PlaylistsTab, ReactNode> = {
updated: <Trans>Updated</Trans>,
new: <Trans>New</Trans>,
dumps: <Trans>Most dumps</Trans>,
mine: <Trans>Mine</Trans>,
};
/** Renders one tab per entry of `tabs`, `sort` being the active one. */
export function PlaylistSortTabs<T extends PlaylistsTab>(
{ tabs, sort, onChange, leading }: PlaylistSortTabsProps<T>,
): React.JSX.Element {
return (
<TabBar
tabs={tabs.map((key) => ({ key, label: LABELS[key] }))}
activeTab={sort}
onChange={onChange}
leading={leading}
className="playlist-sort-tabs"
/>
);
}

View File

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

View File

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

View File

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

View File

@@ -106,7 +106,6 @@ function FollowedPlaylists({ userId }: { userId: string }) {
usePlaylistListSync(
state.status === "loaded" ? state.data.items : [],
setItems,
{ noNewEntries: true },
);
return (
<ListState

View File

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

View File

@@ -11,7 +11,13 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { userApiUrl } from "../../utils/shared/urls.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import {
parsePlaylistSort,
type Playlist,
PLAYLIST_SORTS,
type PlaylistSort,
} from "../../../shared/playlist.ts";
import { playlistSortQuery } from "../../../shared/paths.ts";
import type { PublicUser } from "../../../shared/user.ts";
import {
canLoadMore,
@@ -39,6 +45,7 @@ import { useCategoryFilter } from "../../hooks/category/useCategoryFilter.ts";
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
import { PlaylistSortTabs } from "../playlist/PlaylistSortTabs.tsx";
import { FeedFooter } from "../feed/FeedFooter.tsx";
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
@@ -53,19 +60,22 @@ export interface ProfilePlaylistsTabProps {
/**
* Renders the tab's list for the playlist category the URL's `?category=`
* picks, or every playlist without one. Each category's list is another,
* which the tab mounts afresh.
* picks, or every playlist without one, in the order its `?sort=` names.
* Each category's list in each order is another, which the tab mounts
* afresh.
*/
export function ProfilePlaylistsTab(
props: ProfilePlaylistsTabProps,
): React.JSX.Element {
const [params] = useSearchParams();
const categorySlug = params.get("category") || undefined;
const sort = parsePlaylistSort(params.get("sort"));
return (
<PlaylistList
key={categorySlug ?? ""}
key={`${categorySlug ?? ""}:${sort}`}
{...props}
categorySlug={categorySlug}
sort={sort}
/>
);
}
@@ -74,26 +84,28 @@ export function ProfilePlaylistsTab(
* Renders the playlists `user` made, from `GET /api/users/:userId/playlists`,
* the private ones to their owner only, a page at a time as the list scrolls;
* with `categorySlug`, only those in that playlist category, which the menu in
* the title row switches. The socket keeps them live, and going back restores
* the list and the scroll. The owner can make one, which goes first, and
* delete one.
* the title row switches, before the tabs that change the order. The socket
* keeps them live, and going back restores the list and the scroll. The owner
* can make one, which goes first, and delete one.
*/
function PlaylistList(
{ user, isOwnProfile, categorySlug }: ProfilePlaylistsTabProps & {
{ user, isOwnProfile, categorySlug, sort }: ProfilePlaylistsTabProps & {
categorySlug?: string;
sort: PlaylistSort;
},
): React.JSX.Element {
const [, setParams] = useSearchParams();
const { user: me, authFetch } = useAuth();
const { categories } = useCategories("playlist");
const { cached, saveState } = useFeedCache<Playlist>(
`feed:profile-playlists:${user.id}${
categorySlug ? `:${categorySlug}` : ""
}`,
`feed:profile-playlists:${user.id}:${categorySlug ?? ""}:${sort}`,
hydratePlaylist,
);
const url = `${userApiUrl(user, "playlists")}${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
playlistSortQuery(
sort,
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
)
}`;
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
Playlist
@@ -116,6 +128,7 @@ function PlaylistList(
usePlaylistListSync(loaded?.items ?? [], setItems, {
isOwner: isOwnProfile,
ownerId: user.id,
sort,
});
useOnReconnect(resync);
@@ -149,15 +162,25 @@ function PlaylistList(
title={<Trans>Playlists</Trans>}
actions={
<div className="profile-section-actions">
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) =>
setParams((p) => {
if (slug) p.set("category", slug);
else p.delete("category");
return p;
})}
<PlaylistSortTabs
tabs={PLAYLIST_SORTS}
sort={sort}
// Another order of the same list, as a tab is: it takes the
// current history entry's place.
onChange={(s) =>
setParams((p) => playlistSortQuery(s, p), { replace: true })}
leading={
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) =>
setParams((p) => {
if (slug) p.set("category", slug);
else p.delete("category");
return p;
})}
/>
}
/>
{isOwnProfile && (
<NewPlaylistButton

View File

@@ -6,7 +6,7 @@
*/
import { useContext, useState } from "react";
import { userApiUrl } from "../../utils/shared/urls.ts";
import { imageProps, userApiUrl } from "../../utils/shared/urls.ts";
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
/**
@@ -25,8 +25,9 @@ export interface AvatarProps {
}
/**
* Renders the image from `GET /api/users/:userId/avatar`, or the user's initial
* when they have none or it fails to load. An avatar added, changed or removed
* Renders the image from `GET /api/users/:userId/avatar`, scaled by the server
* to the size it is shown at, or the user's initial when they have none or it
* fails to load. An avatar added, changed or removed
* since the caller's data was fetched shows at once, through the
* `user_updated` messages that `WSProvider` puts in `AvatarOverrideContext`.
*/
@@ -55,7 +56,7 @@ export function Avatar(
: userApiUrl({ id: userId }, "avatar");
return (
<img
src={src}
{...imageProps(src, size)}
alt={username}
title={username}
style={sizeStyle}

View File

@@ -27,9 +27,9 @@ export interface TabBarProps<T extends string> {
/** An extra class for the row. */
innerClassName?: string;
/**
* Optional content rendered inside the row, before the tabs (e.g. a scope
* selector). Sits in the same flex line, aligned with them, and stays pinned
* at the row's start while they scroll under it.
* Optional content rendered before the tabs (e.g. a scope selector). Sits
* in the same line, aligned with them, and stays where it is while they
* scroll: it is outside the row that scrolls, which clips them at its edge.
*/
leading?: ReactNode;
}
@@ -43,7 +43,6 @@ export function TabBar<T extends string>(
TabBarProps<T>,
): React.JSX.Element {
const rowRef = useRef<HTMLDivElement>(null);
const leadingRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState(false);
// Tabs hidden past either edge, which the stylesheet fades only then, so a
// fade never dims a tab that is fully in view.
@@ -69,19 +68,17 @@ export function TabBar<T extends string>(
};
}, [tabs.length]);
// When they don't all fit, keep the active tab in view, between the leading
// content pinned over the row's left edge and the fade over its right edge,
// scrolling the row alone, never the page.
// When they don't all fit, keep the active tab in view, clear of the fade
// over the row's right edge, scrolling the row alone, never the page.
useLayoutEffect(() => {
const row = rowRef.current;
const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active");
if (!row || !active || !overflowing) return;
// The fade is 2rem wide (.feed-sort-scroller::after).
// The fade is 2rem wide (--tab-fade-end on .feed-sort).
const fade =
parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
const r = row.getBoundingClientRect(), b = active.getBoundingClientRect();
const left = r.left + (leadingRef.current?.offsetWidth ?? 0);
if (b.left < left) row.scrollLeft -= left - b.left;
if (b.left < r.left) row.scrollLeft -= r.left - b.left;
else if (b.right > r.right - fade) {
row.scrollLeft += b.right - r.right + fade;
}
@@ -95,13 +92,11 @@ export function TabBar<T extends string>(
className ? ` ${className}` : ""
}`}
>
{leading && <div className="feed-sort-leading">{leading}</div>}
<div
ref={rowRef}
className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}
>
{leading && (
<div ref={leadingRef} className="feed-sort-leading">{leading}</div>
)}
{tabs.map(({ key, label }) => (
<button
key={key}

View File

@@ -7,6 +7,7 @@
import { useState } from "react";
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import { imageProps, isApiImageUrl } from "../../utils/shared/urls.ts";
import {
ThumbnailPlaceholder,
type ThumbnailPlaceholderProps,
@@ -23,6 +24,12 @@ export interface ThumbnailProps {
/** Applies to the image only. */
className?: string;
loading?: "lazy" | "eager";
/**
* The width the image is laid out at, as `imageProps` takes it. An image
* the API serves, the site's own or a proxied one, then loads no larger
* than that; another site's loads as that site sends it.
*/
sizes: number | string;
/** Shown when there's no `src` or it fails to load. */
placeholder: Omit<ThumbnailPlaceholderProps, "className">;
placeholderClassName?: string;
@@ -42,6 +49,7 @@ export function Thumbnail(
alt = "",
className,
loading = "lazy",
sizes,
placeholder,
placeholderClassName,
}: ThumbnailProps,
@@ -64,11 +72,10 @@ export function Thumbnail(
);
}
const url = proxyImageUrl(src, { force: attempt === "proxied" });
return (
<img
src={attempt === "proxied"
? proxyImageUrl(src, { force: true })
: proxyImageUrl(src)}
{...(isApiImageUrl(url) ? imageProps(url, sizes) : { src: url })}
alt={alt}
className={className}
loading={loading}

View File

@@ -0,0 +1,196 @@
/**
* Renders the community feed: who is online, the week's figures, and the
* latest things the site's members did in public, each a line with links to
* who and what it tells of. The GeoCities style's `Marquee` scrolls it, and
* any style may show it: it is a plain list, which the style lays out.
*
* @module
*/
import { Link } from "react-router";
import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react";
import type {
CommunityEvent,
CommunityFeed as CommunityFeedData,
} from "../../../shared/community.ts";
import { DIGEST_MESSAGES } from "../../../shared/digest-messages.ts";
import {
communityPath,
dumpPath,
playlistPath,
userPath,
} from "../../../shared/paths.ts";
import type { OnlineUser } from "../../../shared/ws.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
// Past this many, the online line counts the others: "+3".
const ONLINE_MAX = 8;
/** Passed to {@link CommunityFeed} by whatever shows the feed. */
export interface CommunityFeedProps {
/** From `useCommunityFeed`. */
feed: CommunityFeedData;
/** From `useWSPresence`: the feed opens on them. */
online: OnlineUser[];
/**
* Given to every link. `-1` takes them out of the tab order, for a second
* copy of the list such as the one the marquee's loop needs.
*/
tabIndex?: number;
}
/**
* Links a name to its profile. It wraps the name, rather than take it as a
* prop, so that a sentence reads `<0>{0}</0> posted <1>{1}</1>` to Lingui, as
* the same sentence of the notifications page does, and shares its
* translation.
*/
function User(
{ children, tabIndex }: { children: string; tabIndex?: number },
) {
return <Link to={userPath(children)} tabIndex={tabIndex}>{children}</Link>;
}
/**
* Renders the feed as a `<ul>`: a line for who is online, one for the week's
* figures, which links to `/community`, one for its most upvoted dump, then
* one per event, newest first, ending on how long ago it was. A line with
* nothing to say is left out. The times are computed at render.
*/
export function CommunityFeed(
{ feed, online, tabIndex }: CommunityFeedProps,
): React.JSX.Element {
const { i18n } = useLingui();
const { totals, chatMessages, top } = feed.week;
const figures = ([
[totals.dumps, DIGEST_MESSAGES.statDumps],
[totals.upvotes, DIGEST_MESSAGES.countUpvotes],
[totals.comments, DIGEST_MESSAGES.countComments],
[chatMessages, DIGEST_MESSAGES.statChat],
] as const)
.filter(([n]) => n > 0)
.map(([n, label]) =>
`${i18n.number(n)} ${i18n._({ ...label, values: { n } })}`
)
.join(", ");
const names = (
<>
{online.slice(0, ONLINE_MAX).map((u, i) => (
<span key={u.userId}>
{i > 0 && ", "}
<User tabIndex={tabIndex}>{u.username}</User>
</span>
))}
{online.length > ONLINE_MAX && ` +${online.length - ONLINE_MAX}`}
</>
);
function sentence(event: CommunityEvent) {
switch (event.type) {
case "dump_posted":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" posted "}
<Link to={dumpPath(event.dump)} tabIndex={tabIndex}>
{event.dump.title}
</Link>
</Trans>
);
case "dump_upvoted":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" upvoted "}
<Link to={dumpPath(event.dump)} tabIndex={tabIndex}>
{event.dump.title}
</Link>
</Trans>
);
case "dump_commented":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" commented on "}
<Link
to={`${dumpPath(event.dump)}#comment-${event.commentId}`}
tabIndex={tabIndex}
>
{event.dump.title}
</Link>
</Trans>
);
case "playlist_created":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" created the playlist "}
<Link to={playlistPath(event.playlist)} tabIndex={tabIndex}>
{event.playlist.title}
</Link>
</Trans>
);
case "user_joined":
return event.invitedByUsername
? (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" joined, invited by "}
<User tabIndex={tabIndex}>{event.invitedByUsername}</User>
</Trans>
)
: (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User> joined
</Trans>
);
}
}
return (
<ul
className="community-feed"
aria-label={i18n._(DIGEST_MESSAGES.community)}
>
{online.length > 0 && (
<li className="community-feed-item community-feed-item--online">
<Trans>
<span className="community-feed-label">Online now:</span> {names}
</Trans>
</li>
)}
{figures && (
<li className="community-feed-item community-feed-item--week">
<Trans>
<span className="community-feed-label">This week:</span>{" "}
<Link to={communityPath()} tabIndex={tabIndex}>{figures}</Link>
</Trans>
</li>
)}
{top && (
<li className="community-feed-item community-feed-item--top">
<Trans>
<span className="community-feed-label">Top this week:</span>{" "}
<Link to={dumpPath(top)} tabIndex={tabIndex}>{top.title}</Link>
</Trans>
</li>
)}
{feed.events.map((event) => (
<li
key={event.id}
className={`community-feed-item community-feed-item--${event.type}`}
>
{sentence(event)}{" "}
<time
className="community-feed-time"
dateTime={event.at.toISOString()}
>
{relativeTime(event.at)}
</time>
</li>
))}
</ul>
);
}

View File

@@ -36,6 +36,8 @@ export const WS_URL: string = API_URL
/** The community endpoint, for the community page. */
export const COMMUNITY_URL = `${API_URL}/api/community`;
/** The community feed's endpoint, for `CommunityFeedProvider`. */
export const COMMUNITY_FEED_URL = `${COMMUNITY_URL}/feed`;
/** The search endpoint, for the search page. */
export const SEARCH_URL = `${API_URL}/api/search`;
/** How many items a page of a feed or list asks the API for. */

44
src/config/imageSizes.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* States the width each of the site's images is laid out at, which App.css
* sets, so that `imageProps` asks the server for a copy no larger. A number
* is a fixed width in CSS pixels, a string a `sizes` attribute for a width
* that follows the viewport's. The components that show a thumbnail, a cover
* or a player's artwork take theirs from here; an avatar's is its `size`.
*
* @module
*/
import type { JournalShape } from "../utils/feed/journalLayout.ts";
/** A tile of the journal's grid spanning one of its columns. */
const JOURNAL_COLUMN =
"(max-width: 460px) 100vw, (max-width: 680px) 50vw, (max-width: 980px) 33vw, 290px";
/** A tile of the journal's grid spanning two of its columns. */
const JOURNAL_TWO_COLUMNS =
"(max-width: 680px) 100vw, (max-width: 980px) 66vw, 590px";
/** The width of each image, by the place it is shown. */
export const IMAGE_SIZES = {
/**
* A compact thumbnail at its widest, in a dump card's preview; the rows
* that show it smaller take the same copy.
*/
compactThumbnail: 128,
/** A link's thumbnail beside its text, above it on a narrow screen. */
richThumbnail: "(max-width: 512px) 100vw, 180px",
/** A journal tile's image, by the tile's shape. */
journal: {
feature: JOURNAL_TWO_COLUMNS,
wide: JOURNAL_TWO_COLUMNS,
tall: JOURNAL_COLUMN,
square: JOURNAL_COLUMN,
} satisfies Record<JournalShape, string>,
/** A playlist's cover in a row of a list. */
playlistThumb: 80,
/** A playlist's cover as a tile of the grid, two to a row when narrow. */
playlistTile: "(max-width: 512px) 50vw, 250px",
/** A playlist's cover as the banner of its page. */
playlistBanner: "(max-width: 860px) 100vw, 860px",
/** The artwork in the global player's header. */
playerArtwork: 36,
} as const;

View File

@@ -0,0 +1,36 @@
/**
* Hands the community feed, the latest things the site's members did in
* public, to whatever shows it: the GeoCities style's marquee, for now.
* `CommunityFeedProvider` provides this context and `useCommunityFeed` reads
* it.
*
* @module
*/
import { createContext } from "react";
import type { CommunityFeed } from "../../../shared/community.ts";
/** Provided by `CommunityFeedProvider` through {@link CommunityFeedContext}. */
export interface CommunityFeedContextValue {
/**
* Null until it is fetched, which waits for a first {@link watch}, and
* always for a visitor: the feed is for signed-in users.
*/
feed: CommunityFeed | null;
/**
* Says a component is showing the feed, until the returned call. The feed
* is fetched, and kept up to date, only while one is: most styles show it
* nowhere. `useCommunityFeed` calls it.
*/
watch: () => () => void;
}
/** Given to a consumer outside `CommunityFeedProvider`: no feed. */
export const defaultCommunityFeedValue: CommunityFeedContextValue = {
feed: null,
watch: () => () => {},
};
/** Provided by `CommunityFeedProvider`, and read with `useCommunityFeed`. */
export const CommunityFeedContext: React.Context<CommunityFeedContextValue> =
createContext(defaultCommunityFeedValue);

View File

@@ -0,0 +1,118 @@
/**
* Keeps the community feed for the components that show it, loaded from
* `GET /api/community/feed` and again whenever the socket tells of something
* the feed may hold. `App` mounts it around every page, so that the feed
* outlives the header it is shown in, which every page renders anew.
*
* @module
*/
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { Wire } from "../../../shared/api.ts";
import type { CommunityFeed } from "../../../shared/community.ts";
import { COMMUNITY_FEED_URL } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useFetchState } from "../../hooks/shared/useFetchState.ts";
import { useOnReconnect, useWS, useWSEvent } from "../../hooks/ws/useWS.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { deserializeCommunityFeed } from "../../utils/shared/deserialize.ts";
import {
CommunityFeedContext,
type CommunityFeedContextValue,
} from "./CommunityFeedContext.ts";
/** Passed to {@link CommunityFeedProvider} by `App`. */
export interface CommunityFeedProviderProps {
children: ReactNode;
}
// The feed is fetched again this long after the last change the socket told
// of, so that a run of upvotes costs one request.
const RELOAD_MS = 2000;
/**
* Provides {@link CommunityFeedContext}. It fetches the feed once a component
* watches it, for the signed-in user alone. The socket's messages don't carry
* a feed's lines, an upvote's naming neither its dump nor its voter, so each
* one that may change the feed has it fetched again, and the server's query
* stays the one place that decides what the feed holds. A change that comes
* while nothing watches is caught up with on the next watch.
*/
export function CommunityFeedProvider(
{ children }: CommunityFeedProviderProps,
): React.JSX.Element {
const { user, authFetch } = useAuth();
const [watched, setWatched] = useState(false);
const load = useCallback(
async (signal: AbortSignal) =>
deserializeCommunityFeed(
await expectOk<Wire<CommunityFeed>>(
await authFetch(COMMUNITY_FEED_URL, { signal }),
),
),
[authFetch],
);
const { state, reload } = useFetchState(user?.id, load, {
enabled: watched,
});
const watchers = useRef(0);
// A change came that the feed hasn't been fetched again for.
const due = useRef(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const refresh = useCallback(() => {
if (!due.current || watchers.current === 0) return;
due.current = false;
reload();
}, [reload]);
const changed = useCallback(() => {
due.current = true;
clearTimeout(timer.current);
timer.current = setTimeout(refresh, RELOAD_MS);
}, [refresh]);
useEffect(() => () => clearTimeout(timer.current), []);
useWSEvent("dump", changed);
useWSEvent("vote", changed);
useWSEvent("comment", (e) => e.type !== "updated" && changed());
useWSEvent("playlist", (e) => e.type !== "dumps_updated" && changed());
// A rename changes the names the feed writes.
useWSEvent("user", changed);
useOnReconnect(changed);
// A deleted dump comes as no event, only as a change of this set.
const { deletedDumpIds } = useWS();
const seenDeleted = useRef(deletedDumpIds);
useEffect(() => {
if (seenDeleted.current === deletedDumpIds) return;
seenDeleted.current = deletedDumpIds;
changed();
}, [deletedDumpIds, changed]);
const watch = useCallback(() => {
watchers.current++;
setWatched(true);
refresh();
return () => {
watchers.current--;
};
}, [refresh]);
const feed = state.status === "loaded" ? state.data : null;
const value: CommunityFeedContextValue = useMemo(
() => ({ feed, watch }),
[feed, watch],
);
return (
<CommunityFeedContext.Provider value={value}>
{children}
</CommunityFeedContext.Provider>
);
}

View File

@@ -50,8 +50,11 @@ export interface PlaylistEvent {
* order.
*/
dumpIds?: string[];
/** Set when the dumps changed. It counts private dumps too. */
dumpCount?: number;
/**
* With `dumpIds`, when the latest of them was added; undefined when there
* is none.
*/
lastDumpAt?: Date;
}
/**
@@ -79,6 +82,16 @@ export interface DumpEvent {
dump: Dump;
}
/**
* Keeps the related dumps on a dump's page live. `WSProvider` emits it as a
* `related` event on each `dump_related_changed`, and the `Dump` page fetches
* its related dumps again when `dumpIds` names its dump.
*/
export interface RelatedEvent {
/** The dumps this user may see whose related dumps changed. */
dumpIds: string[];
}
/**
* Keeps a user's profile, and their name on what they posted, up to date.
* `WSProvider` emits it as a `user` event on each `user_updated`, which the
@@ -98,6 +111,7 @@ export interface UserEvent {
export interface WSEvents {
vote: VoteEvent;
dump: DumpEvent;
related: RelatedEvent;
playlist: PlaylistEvent;
comment: CommentEvent;
user: UserEvent;

View File

@@ -538,6 +538,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
break;
}
case "dump_related_changed":
events.emit("related", { dumpIds: msg.dumpIds });
break;
case "vote_ack": {
const { dumpId, action, voteCount } = msg;
// With more clicks in flight, the last ack tells how it ended.
@@ -595,12 +599,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
}
case "playlist_dumps_updated": {
const { playlistId, dumpIds, dumpCount } = msg;
const { playlistId, dumpIds, lastDumpAt } = msg;
events.emit("playlist", {
type: "dumps_updated",
playlistId,
dumpIds,
dumpCount,
lastDumpAt: lastDumpAt ? new Date(lastDumpAt) : undefined,
});
break;
}

View File

@@ -62,7 +62,48 @@
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* nyt */
/* geocities: what stands in for Comic Sans where the system has none (Linux,
Android), so it comes after it in the theme's stack and is only downloaded
there. The package has no latin-ext subset. */
@font-face {
font-family: 'Comic Neue';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/comic-neue/files/comic-neue-latin-400-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Comic Neue';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url(@fontsource/comic-neue/files/comic-neue-latin-400-italic.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Comic Neue';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(@fontsource/comic-neue/files/comic-neue-latin-700-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Comic Neue';
font-style: italic;
font-display: swap;
font-weight: 700;
src: url(@fontsource/comic-neue/files/comic-neue-latin-700-italic.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* nyt: the headlines' serif, the text's (opsz as well as wght), the
interface's sans, and the nameplate's blackletter */
@font-face {
font-family: 'Instrument Serif';
font-style: normal;
@@ -99,6 +140,96 @@
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: normal;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-ext-opsz-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: normal;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-opsz-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: italic;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-ext-opsz-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: italic;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-opsz-italic.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-ext-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-ext-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Manufacturing Consent';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/manufacturing-consent/files/manufacturing-consent-latin-ext-400-normal.woff2)
format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Manufacturing Consent';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/manufacturing-consent/files/manufacturing-consent-latin-400-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* kanagawa */
@font-face {
@@ -138,40 +269,23 @@
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* melange: opsz, SOFT and WONK axes as well as wght */
/* melange: the weight axis alone, which leaves the face at its text optical
size for a headline too. The family has no italic. */
@font-face {
font-family: 'Fraunces Variable';
font-family: 'Bricolage Grotesque Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-normal.woff2)
font-weight: 200 800;
src: url(@fontsource-variable/bricolage-grotesque/files/bricolage-grotesque-latin-ext-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Fraunces Variable';
font-family: 'Bricolage Grotesque Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Fraunces Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Fraunces Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-italic.woff2)
font-weight: 200 800;
src: url(@fontsource-variable/bricolage-grotesque/files/bricolage-grotesque-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}

View File

@@ -12,6 +12,7 @@ import { AuthContext } from "../../contexts/auth/AuthContext.ts";
import type { AuthResponse, SelfUser } from "../../../shared/user.ts";
import type { UserSettings } from "../../../shared/settings.ts";
import type { Session } from "../../utils/auth/storage.ts";
import { takeEarly } from "../../utils/shared/earlyFetch.ts";
import {
forgetStoredSession,
isTokenExpired,
@@ -113,7 +114,13 @@ export function useAuth(): UseAuthResult {
const isFormData = init.body instanceof FormData;
const res = await fetch(input, {
// The page a visit lands on finds its first request already sent, as the
// app booted (utils/shared/earlyFetch.ts).
const early = typeof input === "string" &&
(init.method ?? "GET") === "GET"
? takeEarly(input, token)
: undefined;
const res = await (early ?? fetch(input, {
...init,
headers: {
...(init.headers ?? {}),
@@ -121,7 +128,7 @@ export function useAuth(): UseAuthResult {
// The browser sets FormData's Content-Type, with its boundary.
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
});
}));
if (res.status === 401) {
logout();

View File

@@ -1,90 +1,21 @@
/**
* Gives a dump's card, which is a clickable `<li>` and not a link, a link's
* behaviour: a middle or modified click opens the dump in a new tab.
* Makes a dump's card open the dump as a link would, and mark it visited.
*
* @module
*/
import type { MouseEvent } from "react";
import { useNavigate } from "react-router";
import type { Dump } from "../../../shared/dump.ts";
import { dumpUrl } from "../../utils/shared/urls.ts";
import { markDumpVisited } from "../../utils/shared/visited.ts";
/** Checks whether `e` happened over an anchor, which keeps its own target. */
function isOverLink(e: MouseEvent) {
return (e.target as Element).closest("a") !== null;
}
function wantsNewTab(e: MouseEvent) {
return e.ctrlKey || e.metaKey || e.shiftKey;
}
import { useCardLink, type UseCardLinkResult } from "../shared/useCardLink.ts";
/**
* Returned by {@link useDumpCardLink} to `DumpCard` and `JournalCard`. Every
* way it opens the dump marks it visited, which clears its unread dot.
*/
export interface UseDumpCardLinkResult {
/** Opens the dump in this tab, for a handler that would rather not play. */
go: () => void;
/**
* Opens the dump in a new tab and returns true when `e` is a ctrl, cmd or
* shift click away from any anchor. A click handler that plays media calls
* it first, so that such a click opens the dump instead.
*/
handledAsNewTab: (e: MouseEvent) => boolean;
/** Spread onto the card element. */
cardProps: {
onClick: (e: MouseEvent) => void;
onMouseDown: (e: MouseEvent) => void;
onAuxClick: (e: MouseEvent) => void;
};
}
/**
* Returns the handlers that make the card of `dump` open it: in this tab on a
* click, in a new tab on a middle click or a ctrl, cmd or shift click. Anchors
* inside the card, such as the title or a category, keep their own target.
* Returns `useCardLink`'s handlers for the card of `dump`, for `DumpCard` and
* `JournalCard`: every way they open the dump marks it visited, which clears
* its unread dot.
*/
export function useDumpCardLink(
dump: Pick<Dump, "id" | "slug" | "ownerUsername">,
): UseDumpCardLinkResult {
const navigate = useNavigate();
function openInNewTab() {
markDumpVisited(dump.id);
globalThis.open(dumpUrl(dump), "_blank", "noopener");
}
function go() {
markDumpVisited(dump.id);
navigate(dumpUrl(dump));
}
return {
go,
handledAsNewTab(e: MouseEvent) {
if (!wantsNewTab(e) || isOverLink(e)) return false;
e.preventDefault();
e.stopPropagation();
openInNewTab();
return true;
},
cardProps: {
onClick(e: MouseEvent) {
if (wantsNewTab(e)) openInNewTab();
else go();
},
// Windows and Linux browsers start autoscroll on a middle press, which
// eats the auxclick that follows.
onMouseDown(e: MouseEvent) {
if (e.button === 1 && !isOverLink(e)) e.preventDefault();
},
onAuxClick(e: MouseEvent) {
if (e.button !== 1 || isOverLink(e)) return;
e.preventDefault();
openInNewTab();
},
},
};
): UseCardLinkResult {
return useCardLink(dumpUrl(dump), () => markDumpVisited(dump.id));
}

View File

@@ -7,10 +7,14 @@
*/
import { useEffect, useLayoutEffect, useRef } from "react";
import type { Playlist } from "../../../shared/playlist.ts";
import {
comparePlaylists,
type Playlist,
type PlaylistSort,
} from "../../../shared/playlist.ts";
import { useWS, useWSEvent } from "../ws/useWS.ts";
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
import { withByDate } from "../../utils/feed/pagedList.ts";
import { withInOrder } from "../../utils/feed/pagedList.ts";
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
export interface PlaylistListSyncOptions {
@@ -22,20 +26,27 @@ export interface PlaylistListSyncOptions {
*/
ownerId?: string;
/**
* Adds no new playlist, for a list of followed playlists, since the socket
* doesn't say what the user follows, or of search results. Playlists
* already listed still change, go, and come back where they were.
* The order the list is in, for one the API sorts: the playlists page's and
* a profile's. A list in an order of its own leaves it out, the followed
* playlists being by the date they were followed and a search's results
* the search's: it takes no new playlist, since the socket says neither
* what the user follows nor what a search matches, and moves none.
* Playlists already listed still change, go, and come back where they
* were.
*/
noNewEntries?: boolean;
sort?: PlaylistSort;
}
/**
* Applies the socket's playlist events to `items`, the loaded list, newest
* first, which `setPlaylists` updates. A created playlist goes where its date
* puts it, first when new, an updated one changes in place or leaves when it
* turns private (unless `isOwner`), one that left comes back where it was
* when it returns, a deleted one leaves, and a playlist's `dumpCount` follows
* its dumps. A renamed user's playlists take their new name.
* Applies the socket's playlist events to `items`, the loaded list, which
* `setPlaylists` updates. A created playlist goes where the list's order puts
* it, an updated one changes in place or leaves when it turns private (unless
* `isOwner`), one that left comes back where it was when it returns, a
* deleted one leaves, and a playlist's `dumpCount` and `lastDumpAt` follow
* its dumps. One that a change brings up the order moves up to its place, as
* a playlist does in the `updated` order when it takes a dump; one that falls
* stays where it is, its place being perhaps on a page not loaded. A renamed
* user's playlists take their new name.
*/
export function usePlaylistListSync(
items: Playlist[],
@@ -53,7 +64,14 @@ export function usePlaylistListSync(
});
useWSEvent("playlist", (ev) => {
const { isOwner, ownerId, noNewEntries } = options ?? {};
const { isOwner, ownerId, sort } = options ?? {};
const compare = sort && comparePlaylists(sort);
// Returns `list` with the playlist at `idx` before the ones it now comes
// before, if it rose.
const risen = (list: Playlist[], idx: number) =>
compare && idx > 0 && compare(list[idx], list[idx - 1]) < 0
? withInOrder(list.toSpliced(idx, 1), list[idx], compare)
: list;
const shown = ev.playlist &&
(ev.playlist.isPublic || isOwner) &&
(!ownerId || ev.playlist.userId === ownerId);
@@ -66,15 +84,13 @@ export function usePlaylistListSync(
const idx = prev.findIndex((p) => p.id === playlist.id);
if (idx !== -1) {
if (!shown) return prev.filter((p) => p.id !== playlist.id);
const next = [...prev];
next[idx] = playlist;
return next;
return risen(prev.with(idx, playlist), idx);
}
if (!shown) return prev;
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
// An update never adds a playlist the list doesn't hold.
if (ev.type === "updated" || noNewEntries) return prev;
return withByDate(prev, playlist);
if (ev.type === "updated" || !compare) return prev;
return withInOrder(prev, playlist, compare);
}
if (ev.type === "deleted") {
@@ -84,12 +100,13 @@ export function usePlaylistListSync(
if (ev.type === "dumps_updated" && ev.dumpIds) {
const idx = prev.findIndex((p) => p.id === ev.playlistId);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = {
...next[idx],
dumpCount: ev.dumpCount ?? ev.dumpIds.length,
// The dumps this user may see, which are those the playlist counts.
const playlist = {
...prev[idx],
dumpCount: ev.dumpIds.length,
lastDumpAt: ev.lastDumpAt,
};
return next;
return risen(prev.with(idx, playlist), idx);
}
return prev;

View File

@@ -0,0 +1,91 @@
/**
* Gives a dump's or a playlist's card, which is a clickable `<li>` and not a
* link, a link's behaviour: a middle or modified click opens its page in a
* new tab.
*
* @module
*/
import type { MouseEvent } from "react";
import { useNavigate } from "react-router";
/** Checks whether `e` happened over an anchor, which keeps its own target. */
function isOverLink(e: MouseEvent) {
return (e.target as Element).closest("a") !== null;
}
function wantsNewTab(e: MouseEvent) {
return e.ctrlKey || e.metaKey || e.shiftKey;
}
/**
* Returned by {@link useCardLink} to `PlaylistCard`, and through
* `useDumpCardLink` to `DumpCard` and `JournalCard`.
*/
export interface UseCardLinkResult {
/** Opens the page in this tab, for a handler that would rather not play. */
go: () => void;
/**
* Opens the page in a new tab and returns true when `e` is a ctrl, cmd or
* shift click away from any anchor. A click handler that plays media calls
* it first, so that such a click opens the page instead.
*/
handledAsNewTab: (e: MouseEvent) => boolean;
/** Spread onto the card element. */
cardProps: {
onClick: (e: MouseEvent) => void;
onMouseDown: (e: MouseEvent) => void;
onAuxClick: (e: MouseEvent) => void;
};
}
/**
* Returns the handlers that make a card open the page at `url`: in this tab
* on a click, in a new tab on a middle click or a ctrl, cmd or shift click.
* Every way it opens the page calls `onOpen` first, which marks the item
* visited. Anchors inside the card, such as the title or a category, keep
* their own target.
*/
export function useCardLink(
url: string,
onOpen: () => void,
): UseCardLinkResult {
const navigate = useNavigate();
function openInNewTab() {
onOpen();
globalThis.open(url, "_blank", "noopener");
}
function go() {
onOpen();
navigate(url);
}
return {
go,
handledAsNewTab(e: MouseEvent) {
if (!wantsNewTab(e) || isOverLink(e)) return false;
e.preventDefault();
e.stopPropagation();
openInNewTab();
return true;
},
cardProps: {
onClick(e: MouseEvent) {
if (wantsNewTab(e)) openInNewTab();
else go();
},
// Windows and Linux browsers start autoscroll on a middle press, which
// eats the auxclick that follows.
onMouseDown(e: MouseEvent) {
if (e.button === 1 && !isOverLink(e)) e.preventDefault();
},
onAuxClick(e: MouseEvent) {
if (e.button !== 1 || isOverLink(e)) return;
e.preventDefault();
openInNewTab();
},
},
};
}

View File

@@ -51,7 +51,8 @@ export function useRoomBeside(
// The tabs are observed too, since their width changes without their
// box's (a web font arriving, a tab added at sign-in), and re-observed as
// they come and go.
// they come and go. They are the children of a child, the row that
// scrolls them, when a scope switcher comes before it.
const resize = new ResizeObserver(measure);
const mutation = new MutationObserver(observe);
function observe() {
@@ -60,8 +61,13 @@ export function useRoomBeside(
resize.observe(container!);
const kept = container!.querySelector<HTMLElement>(beside!);
if (kept) {
for (const child of kept.children) resize.observe(child);
mutation.observe(kept, { childList: true });
for (const child of kept.children) {
resize.observe(child);
if (scrolls(child)) {
for (const inner of child.children) resize.observe(inner);
}
}
mutation.observe(kept, { childList: true, subtree: true });
}
measure();
}
@@ -75,17 +81,26 @@ export function useRoomBeside(
return room;
}
/** Whether `el` scrolls its content sideways, as the row of tabs does. */
function scrolls(el: Element): boolean {
const overflow = getComputedStyle(el).overflowX;
return overflow === "auto" || overflow === "scroll";
}
/**
* Returns how wide the content of `el` runs, from its first child to its last,
* plus its padding. Its box won't do, since a flex row may squeeze it (the tabs
* then scroll) or stretch it, and neither will its scrollWidth, which is at
* least the box.
* least the box. A last child that scrolls counts the same way, at its own
* content's width: it is the one squeezed when a scope switcher sits before
* the tabs, outside the row that scrolls them.
*/
function contentWidth(el: HTMLElement): number {
function contentWidth(el: Element): number {
const first = el.firstElementChild, last = el.lastElementChild;
if (!first || !last) return 0;
const cs = getComputedStyle(el);
return last.getBoundingClientRect().right -
const end = last.getBoundingClientRect();
return (scrolls(last) ? end.left + contentWidth(last) : end.right) -
first.getBoundingClientRect().left + parseFloat(cs.paddingLeft) +
parseFloat(cs.paddingRight);
}

View File

@@ -0,0 +1,21 @@
/**
* Gives a component the community feed, by reading `CommunityFeedContext`.
*
* @module
*/
import { useContext, useEffect } from "react";
import type { CommunityFeed } from "../../../shared/community.ts";
import { CommunityFeedContext } from "../../contexts/social/CommunityFeedContext.ts";
/**
* Returns the community feed, kept up to date for as long as the component is
* mounted, or null until it is loaded and for a visitor. The `CommunityFeed`
* component renders it. Needs `CommunityFeedProvider` above, which `App`
* mounts around every page.
*/
export function useCommunityFeed(): CommunityFeed | null {
const { feed, watch } = useContext(CommunityFeedContext);
useEffect(() => watch(), [watch]);
return feed;
}

View File

@@ -11,6 +11,8 @@ import { isLocale, type Locale } from "../shared/locale.ts";
/**
* Returns the stored locale, else French for a French browser, else English.
* The built page asks for that locale's messages ahead of this module, by the
* same rule (`preloadCatalog` in vite.config.ts): the two must agree.
*/
function detectLocale(): Locale {
const stored = localStorage.getItem("locale");

View File

@@ -1,6 +1,7 @@
/**
* Starts the frontend. It loads the stylesheets and the interface's language,
* then mounts `App` on index.html's `#root`.
* sends the first request of the page the visit lands on, then mounts `App`
* on index.html's `#root`.
*
* A deploy renames every chunk, so a tab opened before it fails to load the
* lazy chunks it hasn't fetched yet. It then reloads to pick up the new build,
@@ -17,6 +18,7 @@ import { I18nProvider } from "@lingui/react";
import { App } from "./App.tsx";
import { loadCatalog } from "./i18n.ts";
import { startLandingRequest } from "./utils/shared/earlyFetch.ts";
import "./fonts.css";
import "./tokens.css";
@@ -42,6 +44,10 @@ globalThis.addEventListener("vite:preloadError", (event) => {
location.reload();
});
// The page this visit lands on is loaded with its route, and asks the API for
// its content once loaded: that request leaves now, with the page's code.
startLandingRequest(location.pathname);
// Before the first render, or it would show untranslated message ids.
await loadCatalog();

Some files were not shown because too many files have changed in this diff Show More