/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>
247 lines
8.5 KiB
TypeScript
247 lines
8.5 KiB
TypeScript
/**
|
|
* Builds and parses the site's page paths, by the one rule they all follow:
|
|
* the path is a hierarchy, `@name` is a user (`@name@host` one whose account
|
|
* lives on another instance), and any other word under the
|
|
* root is a dump category or an app page, `/playlists` holding the playlist
|
|
* 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, and so is
|
|
* the order of a list of playlists, `?sort=`, the default order being the
|
|
* bare path.
|
|
*
|
|
* 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
|
|
* frontend's URL, and the server's matchers (the Open Graph tags, the
|
|
* previews and backlinks of links to this site) read paths with
|
|
* {@link parsePagePath}. Migration 0021 rewrote the v3 links in stored text
|
|
* with the builders.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import type { CategoryKind } from "./category.ts";
|
|
import { PLAYLIST_SORTS, type PlaylistSort } from "./playlist.ts";
|
|
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
|
|
import { HANDLE_RE, splitHandle } from "./validation.ts";
|
|
|
|
/**
|
|
* The feed tabs, in the tab bar's order. Each is a path segment: `/hot` for
|
|
* every dump, `/music/hot` for a category. They are reserved at the root, so
|
|
* no category can take one.
|
|
*/
|
|
export const FEED_TABS = ["hot", "new", "journal", "follow"] as const;
|
|
/** One of the {@link FEED_TABS}. */
|
|
export type FeedTab = (typeof FEED_TABS)[number];
|
|
|
|
/**
|
|
* The tabs of a profile, each a segment after `/@name`. The bare `/@name` is
|
|
* the overview.
|
|
*/
|
|
export const PROFILE_TABS = [
|
|
"dumps",
|
|
"upvoted",
|
|
"playlists",
|
|
"follow",
|
|
"invitees",
|
|
"manage",
|
|
] as const;
|
|
/** One of the {@link PROFILE_TABS}. */
|
|
export type ProfileTab = (typeof PROFILE_TABS)[number];
|
|
|
|
/**
|
|
* The views of a `follow` tab, of what is followed: the users, at the bare
|
|
* tab, or the playlists. A profile's lists them, `/@name/follow` and
|
|
* `/@name/follow/playlists`, and a feed's shows their dumps, `/follow` and
|
|
* `/follow/playlists`.
|
|
*/
|
|
export const FOLLOW_VIEWS = ["users", "playlists"] as const;
|
|
/** One of the {@link FOLLOW_VIEWS}. */
|
|
export type FollowView = (typeof FOLLOW_VIEWS)[number];
|
|
|
|
/**
|
|
* The tabs of the search page: the dumps at `/search`, the others at
|
|
* `/search/<tab>`.
|
|
*/
|
|
export const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
|
|
/** One of the {@link SEARCH_TABS}. */
|
|
export type SearchTab = (typeof SEARCH_TABS)[number];
|
|
|
|
/**
|
|
* The views of the community page, one per digest cadence: the week at
|
|
* `/community`, the month at `/community/monthly`.
|
|
*/
|
|
export const COMMUNITY_VIEWS: readonly DigestCadence[] = DIGEST_CADENCES;
|
|
|
|
/** Passed to {@link dumpPath} and {@link playlistPath}, usually as the item. */
|
|
export interface OwnedItem {
|
|
ownerUsername: string;
|
|
slug: string;
|
|
}
|
|
|
|
const seg = encodeURIComponent;
|
|
|
|
/**
|
|
* Writes a handle as a path segment. A host's characters are all safe in a
|
|
* path, so `/@alice@music.example` keeps its `@` rather than `%40`.
|
|
*/
|
|
function handleSeg(handle: string): string {
|
|
const { name, host } = splitHandle(handle);
|
|
return host ? `${seg(name)}@${host}` : seg(name);
|
|
}
|
|
|
|
/**
|
|
* Returns the path of `username`'s profile, of one of its tabs, or of a view
|
|
* of the `follow` tab. The default view, `users`, is the bare tab.
|
|
*/
|
|
export function userPath(
|
|
username: string,
|
|
tab?: ProfileTab,
|
|
view?: FollowView,
|
|
): string {
|
|
const base = `/@${handleSeg(username)}`;
|
|
if (!tab) return base;
|
|
return view && view !== FOLLOW_VIEWS[0]
|
|
? `${base}/${tab}/${view}`
|
|
: `${base}/${tab}`;
|
|
}
|
|
|
|
/**
|
|
* Returns the path of a dump's page, `/@owner/dumps/<slug>`, or of its
|
|
* editor, one segment more.
|
|
*/
|
|
export function dumpPath(dump: OwnedItem, view?: "edit"): string {
|
|
const path = `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`;
|
|
return view ? `${path}/${view}` : path;
|
|
}
|
|
|
|
/**
|
|
* Returns the path of a playlist's page, `/@owner/playlists/<slug>`, or of
|
|
* the page with its editor open, one segment more.
|
|
*/
|
|
export function playlistPath(playlist: OwnedItem, view?: "edit"): string {
|
|
const path = `${userPath(playlist.ownerUsername)}/playlists/${
|
|
seg(playlist.slug)
|
|
}`;
|
|
return view ? `${path}/${view}` : path;
|
|
}
|
|
|
|
/**
|
|
* Returns the path of a feed: every dump's at the root, or a category's at
|
|
* `/<slug>`, on `tab`, or on the viewer's default tab without one. The
|
|
* `follow` tab takes a `view`, the users followed by default.
|
|
*/
|
|
export function feedPath(
|
|
categorySlug?: string,
|
|
tab?: FeedTab,
|
|
view?: FollowView,
|
|
): string {
|
|
const base = categorySlug ? `/${seg(categorySlug)}` : "";
|
|
if (!tab) return base || "/";
|
|
return tab === "follow" && view && view !== FOLLOW_VIEWS[0]
|
|
? `${base}/${tab}/${view}`
|
|
: `${base}/${tab}`;
|
|
}
|
|
|
|
/**
|
|
* Returns the path of the page of every public playlist, `/playlists`, or of a
|
|
* playlist category's at `/playlists/<slug>`, in the order `sort` names, the
|
|
* default one without it.
|
|
*/
|
|
export function playlistsPath(
|
|
categorySlug?: string,
|
|
sort?: PlaylistSort,
|
|
): string {
|
|
const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
|
|
return `${path}${playlistSortQuery(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}` : "";
|
|
}
|
|
|
|
/**
|
|
* Returns the path of the page of the category of `kind` at `slug`: a dump
|
|
* category's feed ({@link feedPath}), or a playlist category's playlists
|
|
* ({@link playlistsPath}). The category chips link to it.
|
|
*/
|
|
export function categoryPath(kind: CategoryKind, slug: string): string {
|
|
return kind === "dump" ? feedPath(slug) : playlistsPath(slug);
|
|
}
|
|
|
|
/**
|
|
* Returns the path of the search page on `tab`, searching `query` when it is
|
|
* given.
|
|
*/
|
|
export function searchPath(tab: SearchTab = "dumps", query?: string): string {
|
|
const base = tab === SEARCH_TABS[0] ? "/search" : `/search/${tab}`;
|
|
return query ? `${base}?q=${seg(query)}` : base;
|
|
}
|
|
|
|
/** Returns the path of the community page on `view`, the week by default. */
|
|
export function communityPath(view: DigestCadence = "weekly"): string {
|
|
return view === COMMUNITY_VIEWS[0] ? "/community" : `/community/${view}`;
|
|
}
|
|
|
|
/**
|
|
* Returned by {@link parsePagePath} for a path under a user. `rest` holds the
|
|
* segments after `/@name`, a profile tab and its view.
|
|
*/
|
|
export type PagePath =
|
|
| { kind: "user"; username: string; rest: string[] }
|
|
| { kind: "dump"; username: string; slug: string; edit: boolean }
|
|
| { kind: "playlist"; username: string; slug: string; edit: boolean };
|
|
|
|
/**
|
|
* Returns what a page path under a user names: a profile (with its tab), a
|
|
* dump or a playlist (or its editor). It returns null for any other path, a
|
|
* feed or an app page, and for one under a user that no page answers. The
|
|
* names are decoded but not looked up, so the user or the item may not exist.
|
|
*/
|
|
export function parsePagePath(pathname: string): PagePath | null {
|
|
let segments: string[];
|
|
try {
|
|
segments = pathname.replace(/\/+$/, "").split("/").slice(1).map(
|
|
decodeURIComponent,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const [handle, section, slug, ...more] = segments;
|
|
if (!handle?.startsWith("@") || !HANDLE_RE.test(handle.slice(1))) {
|
|
return null;
|
|
}
|
|
const username = handle.slice(1);
|
|
|
|
if ((section === "dumps" || section === "playlists") && slug) {
|
|
if (more.length > 1 || (more.length === 1 && more[0] !== "edit")) {
|
|
return null;
|
|
}
|
|
const kind = section === "dumps" ? "dump" : "playlist";
|
|
return { kind, username, slug, edit: more.length === 1 };
|
|
}
|
|
|
|
const rest = segments.slice(1);
|
|
if (rest.length === 0) return { kind: "user", username, rest };
|
|
if (!(PROFILE_TABS as readonly string[]).includes(rest[0])) return null;
|
|
if (rest.length === 1) return { kind: "user", username, rest };
|
|
if (
|
|
rest.length === 2 && rest[0] === "follow" &&
|
|
(FOLLOW_VIEWS as readonly string[]).includes(rest[1]) &&
|
|
rest[1] !== FOLLOW_VIEWS[0]
|
|
) return { kind: "user", username, rest };
|
|
return null;
|
|
}
|