diff --git a/apps/web/src/pages/explorer/Explorer.tsx b/apps/web/src/pages/explorer/Explorer.tsx new file mode 100644 index 00000000..b053141f --- /dev/null +++ b/apps/web/src/pages/explorer/Explorer.tsx @@ -0,0 +1,395 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; +import { useQuery } from "@tanstack/react-query"; +import numeral from "numeral"; +import { useState } from "react"; +import BackButton from "../../components/BackButton"; +import Main from "../../layouts/Main"; +import { rocksky } from "../../lib/rocksky"; +import type { EntityKey } from "./fields"; +import { ENTITIES, entityOf } from "./fields"; +import Results, { type ResultSet } from "./Results"; +import RsqlEditor from "./RsqlEditor"; + +const syntaxTheme = css` + --rsql-field: #7c3aed; + --rsql-op: #0e7490; + --rsql-value: #b45309; + --rsql-string: #047857; + --rsql-logic: #be185d; + --rsql-paren: #64748b; + --rsql-bad: #dc2626; + --explorer-tab: var(--color-purple); + --explorer-run: var(--color-primary); + + .dark & { + --rsql-field: #c4b5fd; + --rsql-op: #67e8f9; + --rsql-value: #fdba74; + --rsql-string: #86efac; + --rsql-logic: #f9a8d4; + --rsql-paren: #a1a1aa; + --rsql-bad: #fca5a5; + } +`; + +const Page = styled.div` + ${syntaxTheme} + margin-top: 70px; + margin-bottom: 160px; +`; + +const Title = styled.h1` + margin: 0 0 4px; + font-size: 1.75rem; + font-family: RockfordSansBold; + color: var(--color-text); +`; + +const Subtitle = styled.p` + margin: 0 0 22px; + font-size: 0.875rem; + color: var(--color-text-muted); +`; + +const Tabs = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +`; + +const TabButton = styled.button<{ active: boolean }>` + padding: 7px 16px; + border-radius: 999px; + border: 1px solid + ${({ active }) => (active ? "transparent" : "rgba(128,128,128,0.28)")}; + background: ${({ active }) => + active ? "var(--explorer-tab)" : "transparent"}; + color: ${({ active }) => (active ? "#fff" : "var(--color-text-muted)")}; + font-family: RockfordSansMedium; + font-size: 0.8125rem; + cursor: pointer; + + &:hover { + color: ${({ active }) => (active ? "#fff" : "var(--color-text)")}; + } +`; + +const Toolbar = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +`; + +const RunButton = styled.button` + padding: 9px 20px; + border: none; + border-radius: 999px; + background: var(--explorer-run); + color: #fff; + font-family: RockfordSansMedium; + font-size: 0.8125rem; + cursor: pointer; + + &:disabled { + opacity: 0.55; + cursor: default; + } +`; + +const GhostButton = styled.button` + padding: 9px 16px; + border-radius: 999px; + border: 1px solid rgba(128, 128, 128, 0.28); + background: transparent; + color: var(--color-text-muted); + font-family: RockfordSansMedium; + font-size: 0.8125rem; + cursor: pointer; + + &:hover { + color: var(--color-text); + } +`; + +const Kbd = styled.span` + margin-left: auto; + font-size: 0.75rem; + color: var(--color-text-muted); + + kbd { + padding: 1px 6px; + border-radius: 5px; + border: 1px solid rgba(128, 128, 128, 0.3); + font-family: var(--font-mono); + font-size: 0.7rem; + } +`; + +const Examples = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 14px; +`; + +const ExampleChip = styled.button` + padding: 5px 12px; + border-radius: 999px; + border: 1px dashed rgba(128, 128, 128, 0.35); + background: transparent; + color: var(--color-text-muted); + font-size: 0.75rem; + cursor: pointer; + + &:hover { + color: var(--color-text); + border-style: solid; + } +`; + +const ExamplesLabel = styled.span` + font-size: 0.75rem; + color: var(--color-text-muted); +`; + +const Error = styled.div` + margin-top: 16px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--rsql-bad); + background: rgba(220, 38, 38, 0.08); + color: var(--color-text); + font-size: 0.8125rem; + font-family: var(--font-mono); + overflow-wrap: anywhere; +`; + +const ResultsHead = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin: 32px 0 14px; +`; + +const ResultsTitle = styled.h2` + margin: 0; + font-size: 1rem; + font-family: RockfordSansMedium; + color: var(--color-text); +`; + +const ResultsCount = styled.span` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-text-muted); +`; + +const Empty = styled.div` + padding: 56px 0; + text-align: center; + color: var(--color-text-muted); + font-size: 0.875rem; +`; + +const PAGE_SIZE = 50; + +async function run( + key: EntityKey, + filter: string, + limit: number, +): Promise { + const client = rocksky(); + const expression = filter.trim() || undefined; + switch (key) { + case "songs": + return { + key, + items: await client.catalogSongs(limit, 0, undefined, expression), + }; + case "albums": + return { + key, + items: await client.catalogAlbums(limit, 0, undefined, expression), + }; + case "artists": + return { + key, + items: await client.catalogArtists(limit, 0, undefined, expression), + }; + case "playlists": { + const out = await client.playlists(limit, 0, expression); + return { key, items: out.playlists ?? [] }; + } + case "scrobbles": + return { + key, + items: await client.scrobbleFeed( + undefined, + false, + limit, + 0, + expression, + ), + }; + } +} + +function Explorer() { + const [entityKey, setEntityKey] = useState("songs"); + const [draft, setDraft] = useState(""); + const [submitted, setSubmitted] = useState<{ + key: EntityKey; + filter: string; + limit: number; + nonce: number; + } | null>(null); + + const entity = entityOf(entityKey); + + const query = useQuery({ + queryKey: [ + "explorer", + submitted?.key, + submitted?.filter, + submitted?.limit, + submitted?.nonce, + ], + enabled: !!submitted, + retry: false, + queryFn: () => run(submitted!.key, submitted!.filter, submitted!.limit), + }); + + const execute = (limit = PAGE_SIZE) => + setSubmitted((prev) => ({ + key: entityKey, + filter: draft, + limit, + nonce: (prev?.nonce ?? 0) + 1, + })); + + const switchEntity = (key: EntityKey) => { + setEntityKey(key); + setDraft(""); + setSubmitted(null); + }; + + const result = query.data; + const count = result?.items.length ?? 0; + const canLoadMore = !!submitted && count >= submitted.limit; + + return ( +
+ + + Explore + + Query the Rocksky catalog with RSQL. Combine predicates with{" "} + ; (and) and , (or), group them with + parentheses, and use * as a wildcard in string values. + + + + {ENTITIES.map((e) => ( + switchEntity(e.key)} + > + {e.label} + + ))} + + + execute()} + /> + + + execute()}> + {query.isFetching ? "Running…" : "Run query"} + + { + setDraft(""); + setSubmitted(null); + }} + > + Clear + + + ↑ ↓ to pick · Tab to complete ·{" "} + Enter to run + + + + + Try: + {entity.examples.map((example) => ( + setDraft(example.filter)} + > + {example.label} + + ))} + + + {query.isError && ( + + {query.error instanceof globalThis.Error + ? query.error.message + : String(query.error)} + + )} + + {submitted && !query.isError && ( + <> + + {entityOf(submitted.key).label} + + {query.isFetching && !result + ? "…" + : `${numeral(count).format("0,0")} result${count === 1 ? "" : "s"}`} + + + {result && count > 0 ? ( + <> + + {canLoadMore && ( +
+ execute(submitted.limit + PAGE_SIZE)} + > + {query.isFetching ? "Loading…" : "Load more"} + +
+ )} + + ) : ( + + {query.isFetching + ? "Running your query…" + : "Nothing matched that filter."} + + )} + + )} +
+
+ ); +} + +export default Explorer; diff --git a/apps/web/src/pages/explorer/Results.tsx b/apps/web/src/pages/explorer/Results.tsx new file mode 100644 index 00000000..c3372bd1 --- /dev/null +++ b/apps/web/src/pages/explorer/Results.tsx @@ -0,0 +1,322 @@ +import styled from "@emotion/styled"; +import type { + AlbumViewBasic, + ArtistViewBasic, + PlaylistViewBasic, + ScrobbleViewBasic, + SongViewBasic, +} from "@rocksky/sdk"; +import { Link } from "@tanstack/react-router"; +import dayjs from "dayjs"; +import numeral from "numeral"; +import type { EntityKey } from "./fields"; + +const Grid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 20px 16px; +`; + +const Rows = styled.div` + display: flex; + flex-direction: column; +`; + +const Row = styled.div` + display: flex; + align-items: center; + gap: 14px; + padding: 8px 10px; + border-radius: 10px; + + &:hover { + background: var(--color-menu-hover); + } +`; + +const Art = styled.img<{ round?: boolean }>` + width: 44px; + height: 44px; + border-radius: ${({ round }) => (round ? "50%" : "6px")}; + object-fit: cover; + flex-shrink: 0; + background: var(--color-menu-hover); +`; + +const CardArt = styled.img<{ round?: boolean }>` + width: 100%; + aspect-ratio: 1; + border-radius: ${({ round }) => (round ? "50%" : "8px")}; + object-fit: cover; + background: var(--color-menu-hover); +`; + +const Mosaic = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + background: var(--color-menu-hover); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +const Body = styled.div` + min-width: 0; + flex: 1; +`; + +const PrimaryText = styled.div` + font-size: 0.9375rem; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const SecondaryText = styled.div` + font-size: 0.8125rem; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Meta = styled.div` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-text-muted); + white-space: nowrap; +`; + +const CardTitle = styled(PrimaryText)` + margin-top: 8px; + font-size: 0.875rem; +`; + +const Tags = styled.div` + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +`; + +const Tag = styled.span` + padding: 1px 8px; + border-radius: 999px; + border: 1px solid rgba(128, 128, 128, 0.3); + font-size: 0.68rem; + color: var(--color-text-muted); +`; + +const PLACEHOLDER = + "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"; + +const rkeyOf = (uri?: string) => uri?.split("/").pop() ?? ""; +const didOf = (uri?: string) => uri?.replace("at://", "").split("/")[0] ?? ""; + +const formatDuration = (ms?: number) => { + if (!ms) return ""; + const total = Math.round(ms / 1000); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; +}; + +function SongRows({ items }: { items: SongViewBasic[] }) { + return ( + + {items.map((song, i) => ( + + + + {song.uri ? ( + + {song.title} + + ) : ( + {song.title} + )} + + {song.artist} + {song.album ? ` — ${song.album}` : ""} + + + {formatDuration(song.duration)} + + ))} + + ); +} + +function AlbumCards({ items }: { items: AlbumViewBasic[] }) { + return ( + + {items.map((album, i) => ( +
+ {album.uri ? ( + + + + ) : ( + + )} + {album.title} + {album.artist} + {album.year ? {album.year} : null} +
+ ))} +
+ ); +} + +function ArtistCards({ items }: { items: ArtistViewBasic[] }) { + return ( + + {items.map((artist, i) => ( +
+ {artist.uri ? ( + + + + ) : ( + + )} + {artist.name} + {artist.playCount ? ( + + {numeral(artist.playCount).format("0,0")} plays + + ) : null} + {artist.tags?.length ? ( + + {artist.tags.slice(0, 3).map((tag) => ( + {tag} + ))} + + ) : null} +
+ ))} +
+ ); +} + +function PlaylistCards({ items }: { items: PlaylistViewBasic[] }) { + return ( + + {items.map((playlist, i) => { + const arts = playlist.trackArts?.filter(Boolean).slice(0, 4) ?? []; + const cover = playlist.coverImageUrl; + const inner = + cover || arts.length < 4 ? ( + + ) : ( + + {arts.map((art) => ( + + ))} + + ); + return ( +
+ {playlist.uri ? ( + + {inner} + + ) : ( + inner + )} + {playlist.title} + + {playlist.curatorName || `@${playlist.curatorHandle}`} + + + {numeral(playlist.trackCount ?? 0).format("0,0")} tracks + +
+ ); + })} +
+ ); +} + +function ScrobbleRows({ items }: { items: ScrobbleViewBasic[] }) { + return ( + + {items.map((scrobble, i) => ( + + + + {scrobble.uri ? ( + + {scrobble.title} + + ) : ( + {scrobble.title} + )} + + {scrobble.artist} + {scrobble.handle ? ` · @${scrobble.handle}` : ""} + + + + {scrobble.createdAt ? dayjs(scrobble.createdAt).fromNow() : ""} + + + ))} + + ); +} + +export type ResultSet = + | { key: "songs"; items: SongViewBasic[] } + | { key: "albums"; items: AlbumViewBasic[] } + | { key: "artists"; items: ArtistViewBasic[] } + | { key: "playlists"; items: PlaylistViewBasic[] } + | { key: "scrobbles"; items: ScrobbleViewBasic[] }; + +export const resultCount = (result: ResultSet | undefined) => + result?.items.length ?? 0; + +function Results({ result }: { result: ResultSet }) { + switch (result.key) { + case "songs": + return ; + case "albums": + return ; + case "artists": + return ; + case "playlists": + return ; + case "scrobbles": + return ; + } +} + +export type { EntityKey }; +export default Results; diff --git a/apps/web/src/pages/explorer/RsqlEditor.tsx b/apps/web/src/pages/explorer/RsqlEditor.tsx new file mode 100644 index 00000000..2acf8287 --- /dev/null +++ b/apps/web/src/pages/explorer/RsqlEditor.tsx @@ -0,0 +1,339 @@ +import styled from "@emotion/styled"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { Entity } from "./fields"; +import { fieldNames, opsFor, valuesFor } from "./fields"; +import { completionAt, tokenize } from "./rsql"; + +const Frame = styled.div` + position: relative; +`; + +const Editor = styled.div` + position: relative; + border-radius: 12px; + border: 1px solid rgba(128, 128, 128, 0.28); + background: var(--color-input-background); + overflow: hidden; + + &:focus-within { + border-color: var(--color-purple); + } +`; + +/* The overlay and the textarea must lay out identically or the coloured text + drifts away from the caret. */ +const shared = ` + margin: 0; + padding: 14px 16px; + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: break-word; + letter-spacing: normal; + tab-size: 2; +`; + +const Highlight = styled.pre` + ${shared} + min-height: 76px; + pointer-events: none; + color: var(--color-text); +`; + +const Input = styled.textarea` + ${shared} + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: none; + outline: none; + resize: none; + background: transparent; + color: transparent; + caret-color: var(--color-text); + + &::selection { + background: color-mix(in srgb, var(--color-purple) 32%, transparent); + } + + &::placeholder { + color: var(--color-text-muted); + } +`; + +const Tok = styled.span<{ kind: string }>` + color: ${({ kind }) => { + switch (kind) { + case "field": + return "var(--rsql-field)"; + case "unknown-field": + return "var(--rsql-bad)"; + case "op": + return "var(--rsql-op)"; + case "value": + return "var(--rsql-value)"; + case "string": + return "var(--rsql-string)"; + case "logic": + return "var(--rsql-logic)"; + case "paren": + return "var(--rsql-paren)"; + case "error": + return "var(--rsql-bad)"; + default: + return "inherit"; + } + }}; + text-decoration: ${({ kind }) => + kind === "error" || kind === "unknown-field" + ? "underline wavy var(--rsql-bad)" + : "none"}; + text-underline-offset: 3px; +`; + +const Menu = styled.ul` + position: absolute; + z-index: 30; + left: 12px; + right: 12px; + top: calc(100% + 6px); + max-height: 260px; + overflow-y: auto; + margin: 0; + padding: 6px; + list-style: none; + border-radius: 12px; + border: 1px solid rgba(128, 128, 128, 0.24); + background: var(--color-background); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28); + + @media (min-width: 640px) { + right: auto; + min-width: 360px; + } +`; + +const Item = styled.li<{ active: boolean }>` + display: flex; + align-items: baseline; + gap: 10px; + padding: 7px 10px; + border-radius: 8px; + cursor: pointer; + background: ${({ active }) => (active ? "var(--color-menu-hover)" : "none")}; +`; + +const ItemName = styled.span` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-text); + white-space: nowrap; +`; + +const ItemType = styled.span` + font-size: 0.68rem; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid rgba(128, 128, 128, 0.3); + color: var(--color-text-muted); + white-space: nowrap; +`; + +const ItemHint = styled.span` + margin-left: auto; + font-size: 0.75rem; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +type Suggestion = { insert: string; label: string; type?: string; hint: string }; + +function RsqlEditor({ + entity, + value, + onChange, + onRun, +}: { + entity: Entity; + value: string; + onChange: (next: string) => void; + onRun: () => void; +}) { + const inputRef = useRef(null); + const highlightRef = useRef(null); + const [caret, setCaret] = useState(0); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + + const known = useMemo(() => fieldNames(entity), [entity]); + const tokens = useMemo(() => tokenize(value, known), [value, known]); + + const completion = useMemo( + () => completionAt(value, caret), + [value, caret], + ); + + const suggestions = useMemo(() => { + const prefix = completion.prefix.toLowerCase(); + if (completion.what === "field") { + return entity.fields + .filter((f) => f.name.toLowerCase().includes(prefix)) + .map((f) => ({ + insert: f.name, + label: f.name, + type: f.type, + hint: f.hint, + })); + } + const field = entity.fields.find((f) => f.name === completion.field); + if (completion.what === "op") { + return opsFor(field?.type ?? "string") + .filter((o) => o.op.startsWith(completion.prefix)) + .map((o) => ({ insert: o.op, label: o.op, hint: o.hint })); + } + return valuesFor(field) + .filter((v) => v.toLowerCase().startsWith(prefix)) + .map((v) => ({ insert: v, label: v, hint: "suggested value" })); + }, [completion, entity]); + + useEffect(() => { + setActive(0); + }, [completion.prefix, completion.what]); + + const accept = useCallback( + (suggestion: Suggestion) => { + const next = + value.slice(0, completion.from) + + suggestion.insert + + value.slice(completion.to); + const at = completion.from + suggestion.insert.length; + onChange(next); + setOpen(false); + requestAnimationFrame(() => { + const el = inputRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(at, at); + setCaret(at); + }); + }, + [completion, onChange, value], + ); + + const syncCaret = () => { + const el = inputRef.current; + if (el) setCaret(el.selectionStart ?? 0); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (open && suggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => (i + 1) % suggestions.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => (i - 1 + suggestions.length) % suggestions.length); + return; + } + if (e.key === "Tab" || (e.key === "Enter" && !e.metaKey && !e.ctrlKey)) { + e.preventDefault(); + accept(suggestions[active]); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + return; + } + } + + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen(false); + onRun(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + setOpen(false); + onRun(); + return; + } + if (e.key === " " && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen(true); + } + }; + + return ( + + + + { + onChange(e.target.value); + setCaret(e.target.selectionStart ?? 0); + setOpen(true); + }} + onScroll={(e) => { + const el = highlightRef.current; + if (el) el.scrollTop = e.currentTarget.scrollTop; + }} + onKeyUp={syncCaret} + onClick={syncCaret} + onFocus={() => setOpen(true)} + onBlur={() => window.setTimeout(() => setOpen(false), 120)} + onKeyDown={onKeyDown} + /> + + {open && suggestions.length > 0 && ( + + {suggestions.map((s, i) => ( + setActive(i)} + onMouseDown={(e) => { + e.preventDefault(); + accept(s); + }} + > + {s.label} + {s.type && {s.type}} + {s.hint} + + ))} + + )} + + ); +} + +export default RsqlEditor; diff --git a/apps/web/src/pages/explorer/fields.ts b/apps/web/src/pages/explorer/fields.ts new file mode 100644 index 00000000..c6b83f19 --- /dev/null +++ b/apps/web/src/pages/explorer/fields.ts @@ -0,0 +1,246 @@ +export type FieldType = "string" | "number" | "date" | "string[]"; + +export type FieldDef = { + name: string; + type: FieldType; + hint: string; +}; + +export type EntityKey = + | "songs" + | "albums" + | "artists" + | "playlists" + | "scrobbles"; + +export type Entity = { + key: EntityKey; + label: string; + fields: FieldDef[]; + examples: { label: string; filter: string }[]; +}; + +const s = (name: string, hint: string): FieldDef => ({ + name, + type: "string", + hint, +}); +const n = (name: string, hint: string): FieldDef => ({ + name, + type: "number", + hint, +}); +const d = (name: string, hint: string): FieldDef => ({ + name, + type: "date", + hint, +}); +const arr = (name: string, hint: string): FieldDef => ({ + name, + type: "string[]", + hint, +}); + +export const ENTITIES: Entity[] = [ + { + key: "songs", + label: "Songs", + fields: [ + s("title", "Track title"), + s("artist", "Track artist"), + s("album", "Album name"), + s("albumArtist", "Album artist"), + s("genre", "Genre"), + s("composer", "Composer"), + s("label", "Record label"), + n("duration", "Length in milliseconds"), + n("trackNumber", "Position on the disc"), + n("discNumber", "Disc number"), + s("mbId", "MusicBrainz ID"), + s("isrc", "ISRC code"), + s("sha256", "Content hash"), + s("uri", "at:// URI"), + s("albumUri", "Album at:// URI"), + s("artistUri", "Artist at:// URI"), + d("createdAt", "First seen on Rocksky"), + ], + examples: [ + { + label: "Long Daft Punk tracks", + filter: 'artist=="Daft Punk";duration=gt=300000', + }, + { + label: "House or techno", + filter: "genre=in=(House,Techno)", + }, + { + label: "Titles starting with Blue", + filter: 'title=="Blue*"', + }, + ], + }, + { + key: "albums", + label: "Albums", + fields: [ + s("title", "Album title"), + s("artist", "Album artist"), + n("year", "Release year"), + s("releaseDate", "Release date"), + s("sha256", "Content hash"), + s("uri", "at:// URI"), + s("artistUri", "Artist at:// URI"), + d("createdAt", "First seen on Rocksky"), + ], + examples: [ + { label: "Nineties albums", filter: "year=ge=1990;year=le=1999" }, + { label: "Radiohead", filter: 'artist=="Radiohead"' }, + { + label: "Recent additions", + filter: "createdAt=gt=2026-01-01", + }, + ], + }, + { + key: "artists", + label: "Artists", + fields: [ + s("name", "Artist name"), + arr("genres", "Genre tags — use ==, !=, =in=, =out="), + s("bornIn", "Place of birth / origin"), + d("born", "Date of birth / formation"), + d("died", "Date of death / split"), + s("sha256", "Content hash"), + s("uri", "at:// URI"), + d("createdAt", "First seen on Rocksky"), + ], + examples: [ + { label: "Tagged jazz", filter: 'genres=="jazz"' }, + { label: "From France", filter: 'bornIn=="*France*"' }, + { + label: "Electronic or ambient", + filter: "genres=in=(electronic,ambient)", + }, + ], + }, + { + key: "playlists", + label: "Playlists", + fields: [ + s("name", "Playlist name"), + s("title", "Alias of name"), + s("description", "Playlist description"), + s("uri", "at:// URI"), + s("spotifyLink", "Spotify URL"), + s("tidalLink", "Tidal URL"), + s("appleMusicLink", "Apple Music URL"), + d("createdAt", "Created at"), + d("updatedAt", "Last updated"), + s("curatorDid", "Curator DID"), + s("curatorHandle", "Curator handle"), + s("curatorName", "Curator display name"), + s("track.title", "Contains a track titled…"), + s("track.artist", "Contains a track by…"), + s("track.album", "Contains a track from album…"), + s("track.albumArtist", "Contains a track whose album artist is…"), + ], + examples: [ + { + label: "Contains Daft Punk", + filter: 'track.artist=="Daft Punk"', + }, + { label: "Named summer", filter: 'name=="*summer*"' }, + { + label: "By one curator", + filter: 'curatorHandle=="tsiry-sandratraina.com"', + }, + ], + }, + { + key: "scrobbles", + label: "Scrobbles", + fields: [ + s("uri", "Scrobble at:// URI"), + d("date", "When it was scrobbled"), + d("timestamp", "Alias of date"), + s("title", "Track title"), + s("artist", "Track artist"), + s("album", "Album name"), + s("track.title", "Track title"), + s("track.artist", "Track artist"), + s("track.album", "Album name"), + s("track.albumArtist", "Album artist"), + s("track.genre", "Track genre"), + n("track.duration", "Length in milliseconds"), + s("track.isrc", "ISRC code"), + s("track.mbId", "MusicBrainz ID"), + s("user.did", "Listener DID"), + s("user.handle", "Listener handle"), + s("user.displayName", "Listener display name"), + s("artist.name", "Artist name"), + arr("artist.genres", "Artist genre tags"), + ], + examples: [ + { + label: "This year, one artist", + filter: 'artist=="Aphex Twin";date=gt=2026-01-01', + }, + { + label: "One listener's jazz", + filter: 'user.handle=="tsiry-sandratraina.com";artist.genres=="jazz"', + }, + { + label: "Tracks over 8 minutes", + filter: "track.duration=gt=480000", + }, + ], + }, +]; + +export const entityOf = (key: EntityKey): Entity => + ENTITIES.find((e) => e.key === key) ?? ENTITIES[0]; + +export const fieldNames = (entity: Entity): Set => + new Set(entity.fields.map((f) => f.name)); + +/** Operators that make sense for a field's type. */ +export function opsFor(type: FieldType): { op: string; hint: string }[] { + if (type === "string[]") { + return [ + { op: "==", hint: "contains the tag" }, + { op: "!=", hint: "does not contain the tag" }, + { op: "=in=", hint: "overlaps any of" }, + { op: "=out=", hint: "overlaps none of" }, + ]; + } + if (type === "number" || type === "date") { + return [ + { op: "==", hint: "equals" }, + { op: "!=", hint: "not equal" }, + { op: "=gt=", hint: "greater than" }, + { op: "=ge=", hint: "greater or equal" }, + { op: "=lt=", hint: "less than" }, + { op: "=le=", hint: "less or equal" }, + { op: "=in=", hint: "one of" }, + { op: "=out=", hint: "none of" }, + ]; + } + return [ + { op: "==", hint: "equals — * is a wildcard" }, + { op: "!=", hint: "not equal" }, + { op: "=in=", hint: "one of" }, + { op: "=out=", hint: "none of" }, + ]; +} + +/** Value suggestions we can offer without hitting the network. */ +export function valuesFor(field: FieldDef | undefined): string[] { + if (!field) return []; + if (field.type === "date") { + return ["2026-01-01", "2025-01-01", "2024-01-01"]; + } + if (field.name === "duration" || field.name === "track.duration") { + return ["180000", "300000", "480000"]; + } + return ["null"]; +} diff --git a/apps/web/src/pages/explorer/index.tsx b/apps/web/src/pages/explorer/index.tsx new file mode 100644 index 00000000..63217e21 --- /dev/null +++ b/apps/web/src/pages/explorer/index.tsx @@ -0,0 +1,3 @@ +import Explorer from "./Explorer"; + +export default Explorer; diff --git a/apps/web/src/pages/explorer/rsql.test.ts b/apps/web/src/pages/explorer/rsql.test.ts new file mode 100644 index 00000000..ceac027e --- /dev/null +++ b/apps/web/src/pages/explorer/rsql.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { completionAt, tokenize } from "./rsql"; + +const KNOWN = new Set([ + "artist", + "duration", + "genre", + "title", + "track.artist", +]); + +const kinds = (input: string) => + tokenize(input, KNOWN) + .filter((t) => t.kind !== "space") + .map((t) => `${t.kind}:${t.text}`); + +describe("tokenize", () => { + it("classifies a compound expression", () => { + expect(kinds('artist=="Daft Punk";duration=gt=300000')).toEqual([ + "field:artist", + "op:==", + 'string:"Daft Punk"', + "logic:;", + "field:duration", + "op:=gt=", + "value:300000", + ]); + }); + + it("classifies list values", () => { + expect(kinds("genre=in=(House,Techno)")).toEqual([ + "field:genre", + "op:=in=", + "paren:(", + "value:House", + "logic:,", + "value:Techno", + "paren:)", + ]); + }); + + it("flags a field that is not in the allowlist", () => { + expect(kinds('bogus=="x"')[0]).toBe("unknown-field:bogus"); + }); + + it("flags an unterminated string", () => { + expect(kinds('artist=="unclosed')).toEqual([ + "field:artist", + "op:==", + 'error:"unclosed', + ]); + }); + + it("reads `and` / `or` as logic, not as a field", () => { + expect(kinds('title=="a" and duration=lt=100')).toEqual([ + "field:title", + "op:==", + 'string:"a"', + "logic:and", + "field:duration", + "op:=lt=", + "value:100", + ]); + }); + + it("keeps dotted selectors whole", () => { + expect(kinds('track.artist=="x"')[0]).toBe("field:track.artist"); + }); + + it("covers the whole input exactly once", () => { + const input = 'artist=="Daft Punk";duration=gt=300000'; + const tokens = tokenize(input, KNOWN); + expect(tokens.map((t) => t.text).join("")).toBe(input); + }); +}); + +describe("completionAt", () => { + it("completes a field from a partial word", () => { + expect(completionAt("art", 3)).toMatchObject({ + what: "field", + prefix: "art", + from: 0, + to: 3, + }); + }); + + it("keeps offering fields mid-word, so artist can still become artistUri", () => { + expect(completionAt("artist", 6)).toMatchObject({ + what: "field", + prefix: "artist", + }); + }); + + it("completes a partially typed operator", () => { + expect(completionAt("artist=", 7)).toMatchObject({ + what: "op", + prefix: "=", + field: "artist", + from: 6, + to: 7, + }); + }); + + it("offers values after a complete operator", () => { + expect(completionAt("artist==", 8)).toMatchObject({ + what: "value", + prefix: "", + field: "artist", + }); + }); + + it("completes a partially typed value", () => { + expect(completionAt("duration=gt=30", 14)).toMatchObject({ + what: "value", + prefix: "30", + field: "duration", + from: 12, + to: 14, + }); + }); + + it("goes back to fields after a logic operator", () => { + expect(completionAt('artist=="A";du', 14)).toMatchObject({ + what: "field", + prefix: "du", + from: 12, + to: 14, + }); + }); +}); diff --git a/apps/web/src/pages/explorer/rsql.ts b/apps/web/src/pages/explorer/rsql.ts new file mode 100644 index 00000000..ae0e5b6f --- /dev/null +++ b/apps/web/src/pages/explorer/rsql.ts @@ -0,0 +1,224 @@ +export type TokenKind = + | "field" + | "unknown-field" + | "op" + | "value" + | "string" + | "logic" + | "paren" + | "space" + | "error"; + +export type Token = { + kind: TokenKind; + text: string; + start: number; + end: number; +}; + +export const COMPARISON_OPS = [ + "==", + "!=", + "=gt=", + "=ge=", + "=lt=", + "=le=", + "=in=", + "=out=", + ">=", + "<=", + ">", + "<", +] as const; + +export const LOGIC_OPS = [";", ",", "and", "or"] as const; + +const SELECTOR_CHAR = /[A-Za-z0-9_.-]/; +const VALUE_CHAR = /[A-Za-z0-9_.:@*+/-]/; + +type Expect = "field" | "op" | "value" | "logic"; + +/** + * Tokenizes an RSQL expression for highlighting and completion. It never + * throws: anything it can't classify comes back as an `error` token so the + * editor can underline it in place. + */ +export function tokenize(input: string, knownFields?: Set): Token[] { + const tokens: Token[] = []; + let i = 0; + let expect: Expect = "field"; + let inList = 0; + + const push = (kind: TokenKind, start: number, end: number) => { + tokens.push({ kind, text: input.slice(start, end), start, end }); + }; + + while (i < input.length) { + const ch = input[i]; + + if (/\s/.test(ch)) { + const start = i; + while (i < input.length && /\s/.test(input[i])) i++; + push("space", start, i); + continue; + } + + if (ch === "(" || ch === ")") { + if (ch === "(" && expect === "value") inList++; + if (ch === ")" && inList > 0) { + inList--; + expect = "logic"; + } + push("paren", i, i + 1); + i++; + if (ch === "(" && inList === 0) expect = "field"; + continue; + } + + if (ch === ";" || ch === ",") { + push("logic", i, i + 1); + i++; + if (!inList) expect = "field"; + continue; + } + + if (ch === '"' || ch === "'") { + const start = i; + const quote = ch; + i++; + let closed = false; + while (i < input.length) { + if (input[i] === "\\") { + i += 2; + continue; + } + if (input[i] === quote) { + i++; + closed = true; + break; + } + i++; + } + push(closed ? "string" : "error", start, i); + if (!inList) expect = "logic"; + continue; + } + + if (expect === "op") { + const op = COMPARISON_OPS.find((candidate) => + input.startsWith(candidate, i), + ); + if (op) { + push("op", i, i + op.length); + i += op.length; + expect = "value"; + continue; + } + const start = i; + while (i < input.length && /[=!<>]/.test(input[i])) i++; + if (i === start) i++; + push("error", start, i); + expect = "value"; + continue; + } + + if (expect === "value") { + const start = i; + while (i < input.length && VALUE_CHAR.test(input[i])) i++; + if (i === start) { + i++; + push("error", start, i); + continue; + } + push("value", start, i); + if (!inList) expect = "logic"; + continue; + } + + if (SELECTOR_CHAR.test(ch)) { + const start = i; + while (i < input.length && SELECTOR_CHAR.test(input[i])) i++; + const word = input.slice(start, i); + + if (expect === "logic" && (word === "and" || word === "or")) { + push("logic", start, i); + expect = "field"; + continue; + } + + const unknown = !!knownFields && !knownFields.has(word); + push(unknown ? "unknown-field" : "field", start, i); + expect = "op"; + continue; + } + + push("error", i, i + 1); + i++; + } + + return tokens; +} + +export type Completion = { + /** What the caret is positioned to complete. */ + what: "field" | "op" | "value"; + /** The partial text already typed, which the suggestion replaces. */ + prefix: string; + /** Range in the source the suggestion replaces. */ + from: number; + to: number; + /** The field the caret's comparison is about, when known. */ + field?: string; +}; + +/** + * What the caret is in the middle of typing. Drives the suggestion list; the + * `from`/`to` range is what an accepted suggestion replaces. + */ +export function completionAt(input: string, caret: number): Completion { + const before = input.slice(0, caret); + + const opMatch = before.match( + /([A-Za-z0-9_.-]+)\s*(==|!=|=gt=|=ge=|=lt=|=le=|=in=|=out=|>=|<=|>|<)\s*([A-Za-z0-9_.:@*+/-]*)$/, + ); + if (opMatch) { + return { + what: "value", + prefix: opMatch[3], + from: caret - opMatch[3].length, + to: caret, + field: opMatch[1], + }; + } + + const partialOp = before.match(/([A-Za-z0-9_.-]+)\s*([=!<>]*)$/); + if (partialOp && partialOp[2].length > 0) { + return { + what: "op", + prefix: partialOp[2], + from: caret - partialOp[2].length, + to: caret, + field: partialOp[1], + }; + } + + const word = before.match(/[A-Za-z0-9_.-]*$/)?.[0] ?? ""; + const previous = before.slice(0, before.length - word.length).trimEnd(); + const afterComparison = /(==|!=|=gt=|=ge=|=lt=|=le=|=in=|=out=|>=|<=|>|<)$/.test( + previous, + ); + if (afterComparison) { + const field = previous + .replace(/(==|!=|=gt=|=ge=|=lt=|=le=|=in=|=out=|>=|<=|>|<)$/, "") + .match(/[A-Za-z0-9_.-]+$/)?.[0]; + return { + what: "value", + prefix: word, + from: caret - word.length, + to: caret, + field, + }; + } + + return { what: "field", prefix: word, from: caret - word.length, to: caret }; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 51ae4f8d..f56c6871 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,8 +17,10 @@ import { Route as RecommendationsRouteImport } from './routes/recommendations' import { Route as MirrorsRouteImport } from './routes/mirrors' import { Route as LoadingRouteImport } from './routes/loading' import { Route as ImportRouteImport } from './routes/import' +import { Route as ExploreRouteImport } from './routes/explore' import { Route as ChartsRouteImport } from './routes/charts' import { Route as ApikeysRouteImport } from './routes/apikeys' +import { Route as AnalyticsRouteImport } from './routes/analytics' import { Route as AccessTokensRouteImport } from './routes/access-tokens' import { Route as IndexRouteImport } from './routes/index' import { Route as LibraryIndexRouteImport } from './routes/library/index' @@ -87,6 +89,11 @@ const ImportRoute = ImportRouteImport.update({ path: '/import', getParentRoute: () => rootRouteImport, } as any) +const ExploreRoute = ExploreRouteImport.update({ + id: '/explore', + path: '/explore', + getParentRoute: () => rootRouteImport, +} as any) const ChartsRoute = ChartsRouteImport.update({ id: '/charts', path: '/charts', @@ -97,6 +104,11 @@ const ApikeysRoute = ApikeysRouteImport.update({ path: '/apikeys', getParentRoute: () => rootRouteImport, } as any) +const AnalyticsRoute = AnalyticsRouteImport.update({ + id: '/analytics', + path: '/analytics', + getParentRoute: () => rootRouteImport, +} as any) const AccessTokensRoute = AccessTokensRouteImport.update({ id: '/access-tokens', path: '/access-tokens', @@ -236,8 +248,10 @@ const DidAlbumRkeyRoute = DidAlbumRkeyRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/access-tokens': typeof AccessTokensRoute + '/analytics': typeof AnalyticsRoute '/apikeys': typeof ApikeysRoute '/charts': typeof ChartsRoute + '/explore': typeof ExploreRoute '/import': typeof ImportRoute '/loading': typeof LoadingRoute '/mirrors': typeof MirrorsRoute @@ -275,8 +289,10 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/access-tokens': typeof AccessTokensRoute + '/analytics': typeof AnalyticsRoute '/apikeys': typeof ApikeysRoute '/charts': typeof ChartsRoute + '/explore': typeof ExploreRoute '/import': typeof ImportRoute '/loading': typeof LoadingRoute '/mirrors': typeof MirrorsRoute @@ -315,8 +331,10 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/access-tokens': typeof AccessTokensRoute + '/analytics': typeof AnalyticsRoute '/apikeys': typeof ApikeysRoute '/charts': typeof ChartsRoute + '/explore': typeof ExploreRoute '/import': typeof ImportRoute '/loading': typeof LoadingRoute '/mirrors': typeof MirrorsRoute @@ -356,8 +374,10 @@ export interface FileRouteTypes { fullPaths: | '/' | '/access-tokens' + | '/analytics' | '/apikeys' | '/charts' + | '/explore' | '/import' | '/loading' | '/mirrors' @@ -395,8 +415,10 @@ export interface FileRouteTypes { to: | '/' | '/access-tokens' + | '/analytics' | '/apikeys' | '/charts' + | '/explore' | '/import' | '/loading' | '/mirrors' @@ -434,8 +456,10 @@ export interface FileRouteTypes { | '__root__' | '/' | '/access-tokens' + | '/analytics' | '/apikeys' | '/charts' + | '/explore' | '/import' | '/loading' | '/mirrors' @@ -474,8 +498,10 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AccessTokensRoute: typeof AccessTokensRoute + AnalyticsRoute: typeof AnalyticsRoute ApikeysRoute: typeof ApikeysRoute ChartsRoute: typeof ChartsRoute + ExploreRoute: typeof ExploreRoute ImportRoute: typeof ImportRoute LoadingRoute: typeof LoadingRoute MirrorsRoute: typeof MirrorsRoute @@ -569,6 +595,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ImportRouteImport parentRoute: typeof rootRouteImport } + '/explore': { + id: '/explore' + path: '/explore' + fullPath: '/explore' + preLoaderRoute: typeof ExploreRouteImport + parentRoute: typeof rootRouteImport + } '/charts': { id: '/charts' path: '/charts' @@ -583,6 +616,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApikeysRouteImport parentRoute: typeof rootRouteImport } + '/analytics': { + id: '/analytics' + path: '/analytics' + fullPath: '/analytics' + preLoaderRoute: typeof AnalyticsRouteImport + parentRoute: typeof rootRouteImport + } '/access-tokens': { id: '/access-tokens' path: '/access-tokens' @@ -778,8 +818,10 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AccessTokensRoute: AccessTokensRoute, + AnalyticsRoute: AnalyticsRoute, ApikeysRoute: ApikeysRoute, ChartsRoute: ChartsRoute, + ExploreRoute: ExploreRoute, ImportRoute: ImportRoute, LoadingRoute: LoadingRoute, MirrorsRoute: MirrorsRoute, diff --git a/apps/web/src/routes/explore.tsx b/apps/web/src/routes/explore.tsx new file mode 100644 index 00000000..c75b3299 --- /dev/null +++ b/apps/web/src/routes/explore.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import ExplorerPage from "../pages/explorer"; + +export const Route = createFileRoute("/explore")({ + component: ExplorerPage, +}); diff --git a/desktop/src/pages/explorer/Explorer.tsx b/desktop/src/pages/explorer/Explorer.tsx new file mode 100644 index 00000000..b053141f --- /dev/null +++ b/desktop/src/pages/explorer/Explorer.tsx @@ -0,0 +1,395 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; +import { useQuery } from "@tanstack/react-query"; +import numeral from "numeral"; +import { useState } from "react"; +import BackButton from "../../components/BackButton"; +import Main from "../../layouts/Main"; +import { rocksky } from "../../lib/rocksky"; +import type { EntityKey } from "./fields"; +import { ENTITIES, entityOf } from "./fields"; +import Results, { type ResultSet } from "./Results"; +import RsqlEditor from "./RsqlEditor"; + +const syntaxTheme = css` + --rsql-field: #7c3aed; + --rsql-op: #0e7490; + --rsql-value: #b45309; + --rsql-string: #047857; + --rsql-logic: #be185d; + --rsql-paren: #64748b; + --rsql-bad: #dc2626; + --explorer-tab: var(--color-purple); + --explorer-run: var(--color-primary); + + .dark & { + --rsql-field: #c4b5fd; + --rsql-op: #67e8f9; + --rsql-value: #fdba74; + --rsql-string: #86efac; + --rsql-logic: #f9a8d4; + --rsql-paren: #a1a1aa; + --rsql-bad: #fca5a5; + } +`; + +const Page = styled.div` + ${syntaxTheme} + margin-top: 70px; + margin-bottom: 160px; +`; + +const Title = styled.h1` + margin: 0 0 4px; + font-size: 1.75rem; + font-family: RockfordSansBold; + color: var(--color-text); +`; + +const Subtitle = styled.p` + margin: 0 0 22px; + font-size: 0.875rem; + color: var(--color-text-muted); +`; + +const Tabs = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +`; + +const TabButton = styled.button<{ active: boolean }>` + padding: 7px 16px; + border-radius: 999px; + border: 1px solid + ${({ active }) => (active ? "transparent" : "rgba(128,128,128,0.28)")}; + background: ${({ active }) => + active ? "var(--explorer-tab)" : "transparent"}; + color: ${({ active }) => (active ? "#fff" : "var(--color-text-muted)")}; + font-family: RockfordSansMedium; + font-size: 0.8125rem; + cursor: pointer; + + &:hover { + color: ${({ active }) => (active ? "#fff" : "var(--color-text)")}; + } +`; + +const Toolbar = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +`; + +const RunButton = styled.button` + padding: 9px 20px; + border: none; + border-radius: 999px; + background: var(--explorer-run); + color: #fff; + font-family: RockfordSansMedium; + font-size: 0.8125rem; + cursor: pointer; + + &:disabled { + opacity: 0.55; + cursor: default; + } +`; + +const GhostButton = styled.button` + padding: 9px 16px; + border-radius: 999px; + border: 1px solid rgba(128, 128, 128, 0.28); + background: transparent; + color: var(--color-text-muted); + font-family: RockfordSansMedium; + font-size: 0.8125rem; + cursor: pointer; + + &:hover { + color: var(--color-text); + } +`; + +const Kbd = styled.span` + margin-left: auto; + font-size: 0.75rem; + color: var(--color-text-muted); + + kbd { + padding: 1px 6px; + border-radius: 5px; + border: 1px solid rgba(128, 128, 128, 0.3); + font-family: var(--font-mono); + font-size: 0.7rem; + } +`; + +const Examples = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 14px; +`; + +const ExampleChip = styled.button` + padding: 5px 12px; + border-radius: 999px; + border: 1px dashed rgba(128, 128, 128, 0.35); + background: transparent; + color: var(--color-text-muted); + font-size: 0.75rem; + cursor: pointer; + + &:hover { + color: var(--color-text); + border-style: solid; + } +`; + +const ExamplesLabel = styled.span` + font-size: 0.75rem; + color: var(--color-text-muted); +`; + +const Error = styled.div` + margin-top: 16px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--rsql-bad); + background: rgba(220, 38, 38, 0.08); + color: var(--color-text); + font-size: 0.8125rem; + font-family: var(--font-mono); + overflow-wrap: anywhere; +`; + +const ResultsHead = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin: 32px 0 14px; +`; + +const ResultsTitle = styled.h2` + margin: 0; + font-size: 1rem; + font-family: RockfordSansMedium; + color: var(--color-text); +`; + +const ResultsCount = styled.span` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-text-muted); +`; + +const Empty = styled.div` + padding: 56px 0; + text-align: center; + color: var(--color-text-muted); + font-size: 0.875rem; +`; + +const PAGE_SIZE = 50; + +async function run( + key: EntityKey, + filter: string, + limit: number, +): Promise { + const client = rocksky(); + const expression = filter.trim() || undefined; + switch (key) { + case "songs": + return { + key, + items: await client.catalogSongs(limit, 0, undefined, expression), + }; + case "albums": + return { + key, + items: await client.catalogAlbums(limit, 0, undefined, expression), + }; + case "artists": + return { + key, + items: await client.catalogArtists(limit, 0, undefined, expression), + }; + case "playlists": { + const out = await client.playlists(limit, 0, expression); + return { key, items: out.playlists ?? [] }; + } + case "scrobbles": + return { + key, + items: await client.scrobbleFeed( + undefined, + false, + limit, + 0, + expression, + ), + }; + } +} + +function Explorer() { + const [entityKey, setEntityKey] = useState("songs"); + const [draft, setDraft] = useState(""); + const [submitted, setSubmitted] = useState<{ + key: EntityKey; + filter: string; + limit: number; + nonce: number; + } | null>(null); + + const entity = entityOf(entityKey); + + const query = useQuery({ + queryKey: [ + "explorer", + submitted?.key, + submitted?.filter, + submitted?.limit, + submitted?.nonce, + ], + enabled: !!submitted, + retry: false, + queryFn: () => run(submitted!.key, submitted!.filter, submitted!.limit), + }); + + const execute = (limit = PAGE_SIZE) => + setSubmitted((prev) => ({ + key: entityKey, + filter: draft, + limit, + nonce: (prev?.nonce ?? 0) + 1, + })); + + const switchEntity = (key: EntityKey) => { + setEntityKey(key); + setDraft(""); + setSubmitted(null); + }; + + const result = query.data; + const count = result?.items.length ?? 0; + const canLoadMore = !!submitted && count >= submitted.limit; + + return ( +
+ + + Explore + + Query the Rocksky catalog with RSQL. Combine predicates with{" "} + ; (and) and , (or), group them with + parentheses, and use * as a wildcard in string values. + + + + {ENTITIES.map((e) => ( + switchEntity(e.key)} + > + {e.label} + + ))} + + + execute()} + /> + + + execute()}> + {query.isFetching ? "Running…" : "Run query"} + + { + setDraft(""); + setSubmitted(null); + }} + > + Clear + + + ↑ ↓ to pick · Tab to complete ·{" "} + Enter to run + + + + + Try: + {entity.examples.map((example) => ( + setDraft(example.filter)} + > + {example.label} + + ))} + + + {query.isError && ( + + {query.error instanceof globalThis.Error + ? query.error.message + : String(query.error)} + + )} + + {submitted && !query.isError && ( + <> + + {entityOf(submitted.key).label} + + {query.isFetching && !result + ? "…" + : `${numeral(count).format("0,0")} result${count === 1 ? "" : "s"}`} + + + {result && count > 0 ? ( + <> + + {canLoadMore && ( +
+ execute(submitted.limit + PAGE_SIZE)} + > + {query.isFetching ? "Loading…" : "Load more"} + +
+ )} + + ) : ( + + {query.isFetching + ? "Running your query…" + : "Nothing matched that filter."} + + )} + + )} +
+
+ ); +} + +export default Explorer; diff --git a/desktop/src/pages/explorer/Results.tsx b/desktop/src/pages/explorer/Results.tsx new file mode 100644 index 00000000..c3372bd1 --- /dev/null +++ b/desktop/src/pages/explorer/Results.tsx @@ -0,0 +1,322 @@ +import styled from "@emotion/styled"; +import type { + AlbumViewBasic, + ArtistViewBasic, + PlaylistViewBasic, + ScrobbleViewBasic, + SongViewBasic, +} from "@rocksky/sdk"; +import { Link } from "@tanstack/react-router"; +import dayjs from "dayjs"; +import numeral from "numeral"; +import type { EntityKey } from "./fields"; + +const Grid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 20px 16px; +`; + +const Rows = styled.div` + display: flex; + flex-direction: column; +`; + +const Row = styled.div` + display: flex; + align-items: center; + gap: 14px; + padding: 8px 10px; + border-radius: 10px; + + &:hover { + background: var(--color-menu-hover); + } +`; + +const Art = styled.img<{ round?: boolean }>` + width: 44px; + height: 44px; + border-radius: ${({ round }) => (round ? "50%" : "6px")}; + object-fit: cover; + flex-shrink: 0; + background: var(--color-menu-hover); +`; + +const CardArt = styled.img<{ round?: boolean }>` + width: 100%; + aspect-ratio: 1; + border-radius: ${({ round }) => (round ? "50%" : "8px")}; + object-fit: cover; + background: var(--color-menu-hover); +`; + +const Mosaic = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + background: var(--color-menu-hover); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +const Body = styled.div` + min-width: 0; + flex: 1; +`; + +const PrimaryText = styled.div` + font-size: 0.9375rem; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const SecondaryText = styled.div` + font-size: 0.8125rem; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Meta = styled.div` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-text-muted); + white-space: nowrap; +`; + +const CardTitle = styled(PrimaryText)` + margin-top: 8px; + font-size: 0.875rem; +`; + +const Tags = styled.div` + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +`; + +const Tag = styled.span` + padding: 1px 8px; + border-radius: 999px; + border: 1px solid rgba(128, 128, 128, 0.3); + font-size: 0.68rem; + color: var(--color-text-muted); +`; + +const PLACEHOLDER = + "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"; + +const rkeyOf = (uri?: string) => uri?.split("/").pop() ?? ""; +const didOf = (uri?: string) => uri?.replace("at://", "").split("/")[0] ?? ""; + +const formatDuration = (ms?: number) => { + if (!ms) return ""; + const total = Math.round(ms / 1000); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; +}; + +function SongRows({ items }: { items: SongViewBasic[] }) { + return ( + + {items.map((song, i) => ( + + + + {song.uri ? ( + + {song.title} + + ) : ( + {song.title} + )} + + {song.artist} + {song.album ? ` — ${song.album}` : ""} + + + {formatDuration(song.duration)} + + ))} + + ); +} + +function AlbumCards({ items }: { items: AlbumViewBasic[] }) { + return ( + + {items.map((album, i) => ( +
+ {album.uri ? ( + + + + ) : ( + + )} + {album.title} + {album.artist} + {album.year ? {album.year} : null} +
+ ))} +
+ ); +} + +function ArtistCards({ items }: { items: ArtistViewBasic[] }) { + return ( + + {items.map((artist, i) => ( +
+ {artist.uri ? ( + + + + ) : ( + + )} + {artist.name} + {artist.playCount ? ( + + {numeral(artist.playCount).format("0,0")} plays + + ) : null} + {artist.tags?.length ? ( + + {artist.tags.slice(0, 3).map((tag) => ( + {tag} + ))} + + ) : null} +
+ ))} +
+ ); +} + +function PlaylistCards({ items }: { items: PlaylistViewBasic[] }) { + return ( + + {items.map((playlist, i) => { + const arts = playlist.trackArts?.filter(Boolean).slice(0, 4) ?? []; + const cover = playlist.coverImageUrl; + const inner = + cover || arts.length < 4 ? ( + + ) : ( + + {arts.map((art) => ( + + ))} + + ); + return ( +
+ {playlist.uri ? ( + + {inner} + + ) : ( + inner + )} + {playlist.title} + + {playlist.curatorName || `@${playlist.curatorHandle}`} + + + {numeral(playlist.trackCount ?? 0).format("0,0")} tracks + +
+ ); + })} +
+ ); +} + +function ScrobbleRows({ items }: { items: ScrobbleViewBasic[] }) { + return ( + + {items.map((scrobble, i) => ( + + + + {scrobble.uri ? ( + + {scrobble.title} + + ) : ( + {scrobble.title} + )} + + {scrobble.artist} + {scrobble.handle ? ` · @${scrobble.handle}` : ""} + + + + {scrobble.createdAt ? dayjs(scrobble.createdAt).fromNow() : ""} + + + ))} + + ); +} + +export type ResultSet = + | { key: "songs"; items: SongViewBasic[] } + | { key: "albums"; items: AlbumViewBasic[] } + | { key: "artists"; items: ArtistViewBasic[] } + | { key: "playlists"; items: PlaylistViewBasic[] } + | { key: "scrobbles"; items: ScrobbleViewBasic[] }; + +export const resultCount = (result: ResultSet | undefined) => + result?.items.length ?? 0; + +function Results({ result }: { result: ResultSet }) { + switch (result.key) { + case "songs": + return ; + case "albums": + return ; + case "artists": + return ; + case "playlists": + return ; + case "scrobbles": + return ; + } +} + +export type { EntityKey }; +export default Results; diff --git a/desktop/src/pages/explorer/RsqlEditor.tsx b/desktop/src/pages/explorer/RsqlEditor.tsx new file mode 100644 index 00000000..2acf8287 --- /dev/null +++ b/desktop/src/pages/explorer/RsqlEditor.tsx @@ -0,0 +1,339 @@ +import styled from "@emotion/styled"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { Entity } from "./fields"; +import { fieldNames, opsFor, valuesFor } from "./fields"; +import { completionAt, tokenize } from "./rsql"; + +const Frame = styled.div` + position: relative; +`; + +const Editor = styled.div` + position: relative; + border-radius: 12px; + border: 1px solid rgba(128, 128, 128, 0.28); + background: var(--color-input-background); + overflow: hidden; + + &:focus-within { + border-color: var(--color-purple); + } +`; + +/* The overlay and the textarea must lay out identically or the coloured text + drifts away from the caret. */ +const shared = ` + margin: 0; + padding: 14px 16px; + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: break-word; + letter-spacing: normal; + tab-size: 2; +`; + +const Highlight = styled.pre` + ${shared} + min-height: 76px; + pointer-events: none; + color: var(--color-text); +`; + +const Input = styled.textarea` + ${shared} + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: none; + outline: none; + resize: none; + background: transparent; + color: transparent; + caret-color: var(--color-text); + + &::selection { + background: color-mix(in srgb, var(--color-purple) 32%, transparent); + } + + &::placeholder { + color: var(--color-text-muted); + } +`; + +const Tok = styled.span<{ kind: string }>` + color: ${({ kind }) => { + switch (kind) { + case "field": + return "var(--rsql-field)"; + case "unknown-field": + return "var(--rsql-bad)"; + case "op": + return "var(--rsql-op)"; + case "value": + return "var(--rsql-value)"; + case "string": + return "var(--rsql-string)"; + case "logic": + return "var(--rsql-logic)"; + case "paren": + return "var(--rsql-paren)"; + case "error": + return "var(--rsql-bad)"; + default: + return "inherit"; + } + }}; + text-decoration: ${({ kind }) => + kind === "error" || kind === "unknown-field" + ? "underline wavy var(--rsql-bad)" + : "none"}; + text-underline-offset: 3px; +`; + +const Menu = styled.ul` + position: absolute; + z-index: 30; + left: 12px; + right: 12px; + top: calc(100% + 6px); + max-height: 260px; + overflow-y: auto; + margin: 0; + padding: 6px; + list-style: none; + border-radius: 12px; + border: 1px solid rgba(128, 128, 128, 0.24); + background: var(--color-background); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28); + + @media (min-width: 640px) { + right: auto; + min-width: 360px; + } +`; + +const Item = styled.li<{ active: boolean }>` + display: flex; + align-items: baseline; + gap: 10px; + padding: 7px 10px; + border-radius: 8px; + cursor: pointer; + background: ${({ active }) => (active ? "var(--color-menu-hover)" : "none")}; +`; + +const ItemName = styled.span` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-text); + white-space: nowrap; +`; + +const ItemType = styled.span` + font-size: 0.68rem; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid rgba(128, 128, 128, 0.3); + color: var(--color-text-muted); + white-space: nowrap; +`; + +const ItemHint = styled.span` + margin-left: auto; + font-size: 0.75rem; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +type Suggestion = { insert: string; label: string; type?: string; hint: string }; + +function RsqlEditor({ + entity, + value, + onChange, + onRun, +}: { + entity: Entity; + value: string; + onChange: (next: string) => void; + onRun: () => void; +}) { + const inputRef = useRef(null); + const highlightRef = useRef(null); + const [caret, setCaret] = useState(0); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + + const known = useMemo(() => fieldNames(entity), [entity]); + const tokens = useMemo(() => tokenize(value, known), [value, known]); + + const completion = useMemo( + () => completionAt(value, caret), + [value, caret], + ); + + const suggestions = useMemo(() => { + const prefix = completion.prefix.toLowerCase(); + if (completion.what === "field") { + return entity.fields + .filter((f) => f.name.toLowerCase().includes(prefix)) + .map((f) => ({ + insert: f.name, + label: f.name, + type: f.type, + hint: f.hint, + })); + } + const field = entity.fields.find((f) => f.name === completion.field); + if (completion.what === "op") { + return opsFor(field?.type ?? "string") + .filter((o) => o.op.startsWith(completion.prefix)) + .map((o) => ({ insert: o.op, label: o.op, hint: o.hint })); + } + return valuesFor(field) + .filter((v) => v.toLowerCase().startsWith(prefix)) + .map((v) => ({ insert: v, label: v, hint: "suggested value" })); + }, [completion, entity]); + + useEffect(() => { + setActive(0); + }, [completion.prefix, completion.what]); + + const accept = useCallback( + (suggestion: Suggestion) => { + const next = + value.slice(0, completion.from) + + suggestion.insert + + value.slice(completion.to); + const at = completion.from + suggestion.insert.length; + onChange(next); + setOpen(false); + requestAnimationFrame(() => { + const el = inputRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(at, at); + setCaret(at); + }); + }, + [completion, onChange, value], + ); + + const syncCaret = () => { + const el = inputRef.current; + if (el) setCaret(el.selectionStart ?? 0); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (open && suggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => (i + 1) % suggestions.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => (i - 1 + suggestions.length) % suggestions.length); + return; + } + if (e.key === "Tab" || (e.key === "Enter" && !e.metaKey && !e.ctrlKey)) { + e.preventDefault(); + accept(suggestions[active]); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + return; + } + } + + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen(false); + onRun(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + setOpen(false); + onRun(); + return; + } + if (e.key === " " && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen(true); + } + }; + + return ( + + + + { + onChange(e.target.value); + setCaret(e.target.selectionStart ?? 0); + setOpen(true); + }} + onScroll={(e) => { + const el = highlightRef.current; + if (el) el.scrollTop = e.currentTarget.scrollTop; + }} + onKeyUp={syncCaret} + onClick={syncCaret} + onFocus={() => setOpen(true)} + onBlur={() => window.setTimeout(() => setOpen(false), 120)} + onKeyDown={onKeyDown} + /> + + {open && suggestions.length > 0 && ( + + {suggestions.map((s, i) => ( + setActive(i)} + onMouseDown={(e) => { + e.preventDefault(); + accept(s); + }} + > + {s.label} + {s.type && {s.type}} + {s.hint} + + ))} + + )} + + ); +} + +export default RsqlEditor; diff --git a/desktop/src/pages/explorer/fields.ts b/desktop/src/pages/explorer/fields.ts new file mode 100644 index 00000000..c6b83f19 --- /dev/null +++ b/desktop/src/pages/explorer/fields.ts @@ -0,0 +1,246 @@ +export type FieldType = "string" | "number" | "date" | "string[]"; + +export type FieldDef = { + name: string; + type: FieldType; + hint: string; +}; + +export type EntityKey = + | "songs" + | "albums" + | "artists" + | "playlists" + | "scrobbles"; + +export type Entity = { + key: EntityKey; + label: string; + fields: FieldDef[]; + examples: { label: string; filter: string }[]; +}; + +const s = (name: string, hint: string): FieldDef => ({ + name, + type: "string", + hint, +}); +const n = (name: string, hint: string): FieldDef => ({ + name, + type: "number", + hint, +}); +const d = (name: string, hint: string): FieldDef => ({ + name, + type: "date", + hint, +}); +const arr = (name: string, hint: string): FieldDef => ({ + name, + type: "string[]", + hint, +}); + +export const ENTITIES: Entity[] = [ + { + key: "songs", + label: "Songs", + fields: [ + s("title", "Track title"), + s("artist", "Track artist"), + s("album", "Album name"), + s("albumArtist", "Album artist"), + s("genre", "Genre"), + s("composer", "Composer"), + s("label", "Record label"), + n("duration", "Length in milliseconds"), + n("trackNumber", "Position on the disc"), + n("discNumber", "Disc number"), + s("mbId", "MusicBrainz ID"), + s("isrc", "ISRC code"), + s("sha256", "Content hash"), + s("uri", "at:// URI"), + s("albumUri", "Album at:// URI"), + s("artistUri", "Artist at:// URI"), + d("createdAt", "First seen on Rocksky"), + ], + examples: [ + { + label: "Long Daft Punk tracks", + filter: 'artist=="Daft Punk";duration=gt=300000', + }, + { + label: "House or techno", + filter: "genre=in=(House,Techno)", + }, + { + label: "Titles starting with Blue", + filter: 'title=="Blue*"', + }, + ], + }, + { + key: "albums", + label: "Albums", + fields: [ + s("title", "Album title"), + s("artist", "Album artist"), + n("year", "Release year"), + s("releaseDate", "Release date"), + s("sha256", "Content hash"), + s("uri", "at:// URI"), + s("artistUri", "Artist at:// URI"), + d("createdAt", "First seen on Rocksky"), + ], + examples: [ + { label: "Nineties albums", filter: "year=ge=1990;year=le=1999" }, + { label: "Radiohead", filter: 'artist=="Radiohead"' }, + { + label: "Recent additions", + filter: "createdAt=gt=2026-01-01", + }, + ], + }, + { + key: "artists", + label: "Artists", + fields: [ + s("name", "Artist name"), + arr("genres", "Genre tags — use ==, !=, =in=, =out="), + s("bornIn", "Place of birth / origin"), + d("born", "Date of birth / formation"), + d("died", "Date of death / split"), + s("sha256", "Content hash"), + s("uri", "at:// URI"), + d("createdAt", "First seen on Rocksky"), + ], + examples: [ + { label: "Tagged jazz", filter: 'genres=="jazz"' }, + { label: "From France", filter: 'bornIn=="*France*"' }, + { + label: "Electronic or ambient", + filter: "genres=in=(electronic,ambient)", + }, + ], + }, + { + key: "playlists", + label: "Playlists", + fields: [ + s("name", "Playlist name"), + s("title", "Alias of name"), + s("description", "Playlist description"), + s("uri", "at:// URI"), + s("spotifyLink", "Spotify URL"), + s("tidalLink", "Tidal URL"), + s("appleMusicLink", "Apple Music URL"), + d("createdAt", "Created at"), + d("updatedAt", "Last updated"), + s("curatorDid", "Curator DID"), + s("curatorHandle", "Curator handle"), + s("curatorName", "Curator display name"), + s("track.title", "Contains a track titled…"), + s("track.artist", "Contains a track by…"), + s("track.album", "Contains a track from album…"), + s("track.albumArtist", "Contains a track whose album artist is…"), + ], + examples: [ + { + label: "Contains Daft Punk", + filter: 'track.artist=="Daft Punk"', + }, + { label: "Named summer", filter: 'name=="*summer*"' }, + { + label: "By one curator", + filter: 'curatorHandle=="tsiry-sandratraina.com"', + }, + ], + }, + { + key: "scrobbles", + label: "Scrobbles", + fields: [ + s("uri", "Scrobble at:// URI"), + d("date", "When it was scrobbled"), + d("timestamp", "Alias of date"), + s("title", "Track title"), + s("artist", "Track artist"), + s("album", "Album name"), + s("track.title", "Track title"), + s("track.artist", "Track artist"), + s("track.album", "Album name"), + s("track.albumArtist", "Album artist"), + s("track.genre", "Track genre"), + n("track.duration", "Length in milliseconds"), + s("track.isrc", "ISRC code"), + s("track.mbId", "MusicBrainz ID"), + s("user.did", "Listener DID"), + s("user.handle", "Listener handle"), + s("user.displayName", "Listener display name"), + s("artist.name", "Artist name"), + arr("artist.genres", "Artist genre tags"), + ], + examples: [ + { + label: "This year, one artist", + filter: 'artist=="Aphex Twin";date=gt=2026-01-01', + }, + { + label: "One listener's jazz", + filter: 'user.handle=="tsiry-sandratraina.com";artist.genres=="jazz"', + }, + { + label: "Tracks over 8 minutes", + filter: "track.duration=gt=480000", + }, + ], + }, +]; + +export const entityOf = (key: EntityKey): Entity => + ENTITIES.find((e) => e.key === key) ?? ENTITIES[0]; + +export const fieldNames = (entity: Entity): Set => + new Set(entity.fields.map((f) => f.name)); + +/** Operators that make sense for a field's type. */ +export function opsFor(type: FieldType): { op: string; hint: string }[] { + if (type === "string[]") { + return [ + { op: "==", hint: "contains the tag" }, + { op: "!=", hint: "does not contain the tag" }, + { op: "=in=", hint: "overlaps any of" }, + { op: "=out=", hint: "overlaps none of" }, + ]; + } + if (type === "number" || type === "date") { + return [ + { op: "==", hint: "equals" }, + { op: "!=", hint: "not equal" }, + { op: "=gt=", hint: "greater than" }, + { op: "=ge=", hint: "greater or equal" }, + { op: "=lt=", hint: "less than" }, + { op: "=le=", hint: "less or equal" }, + { op: "=in=", hint: "one of" }, + { op: "=out=", hint: "none of" }, + ]; + } + return [ + { op: "==", hint: "equals — * is a wildcard" }, + { op: "!=", hint: "not equal" }, + { op: "=in=", hint: "one of" }, + { op: "=out=", hint: "none of" }, + ]; +} + +/** Value suggestions we can offer without hitting the network. */ +export function valuesFor(field: FieldDef | undefined): string[] { + if (!field) return []; + if (field.type === "date") { + return ["2026-01-01", "2025-01-01", "2024-01-01"]; + } + if (field.name === "duration" || field.name === "track.duration") { + return ["180000", "300000", "480000"]; + } + return ["null"]; +} diff --git a/desktop/src/pages/explorer/index.tsx b/desktop/src/pages/explorer/index.tsx new file mode 100644 index 00000000..63217e21 --- /dev/null +++ b/desktop/src/pages/explorer/index.tsx @@ -0,0 +1,3 @@ +import Explorer from "./Explorer"; + +export default Explorer; diff --git a/desktop/src/pages/explorer/rsql.test.ts b/desktop/src/pages/explorer/rsql.test.ts new file mode 100644 index 00000000..ceac027e --- /dev/null +++ b/desktop/src/pages/explorer/rsql.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { completionAt, tokenize } from "./rsql"; + +const KNOWN = new Set([ + "artist", + "duration", + "genre", + "title", + "track.artist", +]); + +const kinds = (input: string) => + tokenize(input, KNOWN) + .filter((t) => t.kind !== "space") + .map((t) => `${t.kind}:${t.text}`); + +describe("tokenize", () => { + it("classifies a compound expression", () => { + expect(kinds('artist=="Daft Punk";duration=gt=300000')).toEqual([ + "field:artist", + "op:==", + 'string:"Daft Punk"', + "logic:;", + "field:duration", + "op:=gt=", + "value:300000", + ]); + }); + + it("classifies list values", () => { + expect(kinds("genre=in=(House,Techno)")).toEqual([ + "field:genre", + "op:=in=", + "paren:(", + "value:House", + "logic:,", + "value:Techno", + "paren:)", + ]); + }); + + it("flags a field that is not in the allowlist", () => { + expect(kinds('bogus=="x"')[0]).toBe("unknown-field:bogus"); + }); + + it("flags an unterminated string", () => { + expect(kinds('artist=="unclosed')).toEqual([ + "field:artist", + "op:==", + 'error:"unclosed', + ]); + }); + + it("reads `and` / `or` as logic, not as a field", () => { + expect(kinds('title=="a" and duration=lt=100')).toEqual([ + "field:title", + "op:==", + 'string:"a"', + "logic:and", + "field:duration", + "op:=lt=", + "value:100", + ]); + }); + + it("keeps dotted selectors whole", () => { + expect(kinds('track.artist=="x"')[0]).toBe("field:track.artist"); + }); + + it("covers the whole input exactly once", () => { + const input = 'artist=="Daft Punk";duration=gt=300000'; + const tokens = tokenize(input, KNOWN); + expect(tokens.map((t) => t.text).join("")).toBe(input); + }); +}); + +describe("completionAt", () => { + it("completes a field from a partial word", () => { + expect(completionAt("art", 3)).toMatchObject({ + what: "field", + prefix: "art", + from: 0, + to: 3, + }); + }); + + it("keeps offering fields mid-word, so artist can still become artistUri", () => { + expect(completionAt("artist", 6)).toMatchObject({ + what: "field", + prefix: "artist", + }); + }); + + it("completes a partially typed operator", () => { + expect(completionAt("artist=", 7)).toMatchObject({ + what: "op", + prefix: "=", + field: "artist", + from: 6, + to: 7, + }); + }); + + it("offers values after a complete operator", () => { + expect(completionAt("artist==", 8)).toMatchObject({ + what: "value", + prefix: "", + field: "artist", + }); + }); + + it("completes a partially typed value", () => { + expect(completionAt("duration=gt=30", 14)).toMatchObject({ + what: "value", + prefix: "30", + field: "duration", + from: 12, + to: 14, + }); + }); + + it("goes back to fields after a logic operator", () => { + expect(completionAt('artist=="A";du', 14)).toMatchObject({ + what: "field", + prefix: "du", + from: 12, + to: 14, + }); + }); +}); diff --git a/desktop/src/pages/explorer/rsql.ts b/desktop/src/pages/explorer/rsql.ts new file mode 100644 index 00000000..ae0e5b6f --- /dev/null +++ b/desktop/src/pages/explorer/rsql.ts @@ -0,0 +1,224 @@ +export type TokenKind = + | "field" + | "unknown-field" + | "op" + | "value" + | "string" + | "logic" + | "paren" + | "space" + | "error"; + +export type Token = { + kind: TokenKind; + text: string; + start: number; + end: number; +}; + +export const COMPARISON_OPS = [ + "==", + "!=", + "=gt=", + "=ge=", + "=lt=", + "=le=", + "=in=", + "=out=", + ">=", + "<=", + ">", + "<", +] as const; + +export const LOGIC_OPS = [";", ",", "and", "or"] as const; + +const SELECTOR_CHAR = /[A-Za-z0-9_.-]/; +const VALUE_CHAR = /[A-Za-z0-9_.:@*+/-]/; + +type Expect = "field" | "op" | "value" | "logic"; + +/** + * Tokenizes an RSQL expression for highlighting and completion. It never + * throws: anything it can't classify comes back as an `error` token so the + * editor can underline it in place. + */ +export function tokenize(input: string, knownFields?: Set): Token[] { + const tokens: Token[] = []; + let i = 0; + let expect: Expect = "field"; + let inList = 0; + + const push = (kind: TokenKind, start: number, end: number) => { + tokens.push({ kind, text: input.slice(start, end), start, end }); + }; + + while (i < input.length) { + const ch = input[i]; + + if (/\s/.test(ch)) { + const start = i; + while (i < input.length && /\s/.test(input[i])) i++; + push("space", start, i); + continue; + } + + if (ch === "(" || ch === ")") { + if (ch === "(" && expect === "value") inList++; + if (ch === ")" && inList > 0) { + inList--; + expect = "logic"; + } + push("paren", i, i + 1); + i++; + if (ch === "(" && inList === 0) expect = "field"; + continue; + } + + if (ch === ";" || ch === ",") { + push("logic", i, i + 1); + i++; + if (!inList) expect = "field"; + continue; + } + + if (ch === '"' || ch === "'") { + const start = i; + const quote = ch; + i++; + let closed = false; + while (i < input.length) { + if (input[i] === "\\") { + i += 2; + continue; + } + if (input[i] === quote) { + i++; + closed = true; + break; + } + i++; + } + push(closed ? "string" : "error", start, i); + if (!inList) expect = "logic"; + continue; + } + + if (expect === "op") { + const op = COMPARISON_OPS.find((candidate) => + input.startsWith(candidate, i), + ); + if (op) { + push("op", i, i + op.length); + i += op.length; + expect = "value"; + continue; + } + const start = i; + while (i < input.length && /[=!<>]/.test(input[i])) i++; + if (i === start) i++; + push("error", start, i); + expect = "value"; + continue; + } + + if (expect === "value") { + const start = i; + while (i < input.length && VALUE_CHAR.test(input[i])) i++; + if (i === start) { + i++; + push("error", start, i); + continue; + } + push("value", start, i); + if (!inList) expect = "logic"; + continue; + } + + if (SELECTOR_CHAR.test(ch)) { + const start = i; + while (i < input.length && SELECTOR_CHAR.test(input[i])) i++; + const word = input.slice(start, i); + + if (expect === "logic" && (word === "and" || word === "or")) { + push("logic", start, i); + expect = "field"; + continue; + } + + const unknown = !!knownFields && !knownFields.has(word); + push(unknown ? "unknown-field" : "field", start, i); + expect = "op"; + continue; + } + + push("error", i, i + 1); + i++; + } + + return tokens; +} + +export type Completion = { + /** What the caret is positioned to complete. */ + what: "field" | "op" | "value"; + /** The partial text already typed, which the suggestion replaces. */ + prefix: string; + /** Range in the source the suggestion replaces. */ + from: number; + to: number; + /** The field the caret's comparison is about, when known. */ + field?: string; +}; + +/** + * What the caret is in the middle of typing. Drives the suggestion list; the + * `from`/`to` range is what an accepted suggestion replaces. + */ +export function completionAt(input: string, caret: number): Completion { + const before = input.slice(0, caret); + + const opMatch = before.match( + /([A-Za-z0-9_.-]+)\s*(==|!=|=gt=|=ge=|=lt=|=le=|=in=|=out=|>=|<=|>|<)\s*([A-Za-z0-9_.:@*+/-]*)$/, + ); + if (opMatch) { + return { + what: "value", + prefix: opMatch[3], + from: caret - opMatch[3].length, + to: caret, + field: opMatch[1], + }; + } + + const partialOp = before.match(/([A-Za-z0-9_.-]+)\s*([=!<>]*)$/); + if (partialOp && partialOp[2].length > 0) { + return { + what: "op", + prefix: partialOp[2], + from: caret - partialOp[2].length, + to: caret, + field: partialOp[1], + }; + } + + const word = before.match(/[A-Za-z0-9_.-]*$/)?.[0] ?? ""; + const previous = before.slice(0, before.length - word.length).trimEnd(); + const afterComparison = /(==|!=|=gt=|=ge=|=lt=|=le=|=in=|=out=|>=|<=|>|<)$/.test( + previous, + ); + if (afterComparison) { + const field = previous + .replace(/(==|!=|=gt=|=ge=|=lt=|=le=|=in=|=out=|>=|<=|>|<)$/, "") + .match(/[A-Za-z0-9_.-]+$/)?.[0]; + return { + what: "value", + prefix: word, + from: caret - word.length, + to: caret, + field, + }; + } + + return { what: "field", prefix: word, from: caret - word.length, to: caret }; +} diff --git a/desktop/src/routeTree.gen.ts b/desktop/src/routeTree.gen.ts index f76ade73..247e83b6 100644 --- a/desktop/src/routeTree.gen.ts +++ b/desktop/src/routeTree.gen.ts @@ -17,8 +17,10 @@ import { Route as RecommendationsRouteImport } from './routes/recommendations' import { Route as MirrorsRouteImport } from './routes/mirrors' import { Route as LoadingRouteImport } from './routes/loading' import { Route as ImportRouteImport } from './routes/import' +import { Route as ExploreRouteImport } from './routes/explore' import { Route as ChartsRouteImport } from './routes/charts' import { Route as ApikeysRouteImport } from './routes/apikeys' +import { Route as AnalyticsRouteImport } from './routes/analytics' import { Route as AccessTokensRouteImport } from './routes/access-tokens' import { Route as IndexRouteImport } from './routes/index' import { Route as LibraryIndexRouteImport } from './routes/library/index' @@ -88,6 +90,11 @@ const ImportRoute = ImportRouteImport.update({ path: '/import', getParentRoute: () => rootRouteImport, } as any) +const ExploreRoute = ExploreRouteImport.update({ + id: '/explore', + path: '/explore', + getParentRoute: () => rootRouteImport, +} as any) const ChartsRoute = ChartsRouteImport.update({ id: '/charts', path: '/charts', @@ -98,6 +105,11 @@ const ApikeysRoute = ApikeysRouteImport.update({ path: '/apikeys', getParentRoute: () => rootRouteImport, } as any) +const AnalyticsRoute = AnalyticsRouteImport.update({ + id: '/analytics', + path: '/analytics', + getParentRoute: () => rootRouteImport, +} as any) const AccessTokensRoute = AccessTokensRouteImport.update({ id: '/access-tokens', path: '/access-tokens', @@ -242,8 +254,10 @@ const DidAlbumRkeyRoute = DidAlbumRkeyRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/access-tokens': typeof AccessTokensRoute + '/analytics': typeof AnalyticsRoute '/apikeys': typeof ApikeysRoute '/charts': typeof ChartsRoute + '/explore': typeof ExploreRoute '/import': typeof ImportRoute '/loading': typeof LoadingRoute '/mirrors': typeof MirrorsRoute @@ -282,8 +296,10 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/access-tokens': typeof AccessTokensRoute + '/analytics': typeof AnalyticsRoute '/apikeys': typeof ApikeysRoute '/charts': typeof ChartsRoute + '/explore': typeof ExploreRoute '/import': typeof ImportRoute '/loading': typeof LoadingRoute '/mirrors': typeof MirrorsRoute @@ -323,8 +339,10 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/access-tokens': typeof AccessTokensRoute + '/analytics': typeof AnalyticsRoute '/apikeys': typeof ApikeysRoute '/charts': typeof ChartsRoute + '/explore': typeof ExploreRoute '/import': typeof ImportRoute '/loading': typeof LoadingRoute '/mirrors': typeof MirrorsRoute @@ -365,8 +383,10 @@ export interface FileRouteTypes { fullPaths: | '/' | '/access-tokens' + | '/analytics' | '/apikeys' | '/charts' + | '/explore' | '/import' | '/loading' | '/mirrors' @@ -405,8 +425,10 @@ export interface FileRouteTypes { to: | '/' | '/access-tokens' + | '/analytics' | '/apikeys' | '/charts' + | '/explore' | '/import' | '/loading' | '/mirrors' @@ -445,8 +467,10 @@ export interface FileRouteTypes { | '__root__' | '/' | '/access-tokens' + | '/analytics' | '/apikeys' | '/charts' + | '/explore' | '/import' | '/loading' | '/mirrors' @@ -486,8 +510,10 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AccessTokensRoute: typeof AccessTokensRoute + AnalyticsRoute: typeof AnalyticsRoute ApikeysRoute: typeof ApikeysRoute ChartsRoute: typeof ChartsRoute + ExploreRoute: typeof ExploreRoute ImportRoute: typeof ImportRoute LoadingRoute: typeof LoadingRoute MirrorsRoute: typeof MirrorsRoute @@ -582,6 +608,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ImportRouteImport parentRoute: typeof rootRouteImport } + '/explore': { + id: '/explore' + path: '/explore' + fullPath: '/explore' + preLoaderRoute: typeof ExploreRouteImport + parentRoute: typeof rootRouteImport + } '/charts': { id: '/charts' path: '/charts' @@ -596,6 +629,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApikeysRouteImport parentRoute: typeof rootRouteImport } + '/analytics': { + id: '/analytics' + path: '/analytics' + fullPath: '/analytics' + preLoaderRoute: typeof AnalyticsRouteImport + parentRoute: typeof rootRouteImport + } '/access-tokens': { id: '/access-tokens' path: '/access-tokens' @@ -798,8 +838,10 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AccessTokensRoute: AccessTokensRoute, + AnalyticsRoute: AnalyticsRoute, ApikeysRoute: ApikeysRoute, ChartsRoute: ChartsRoute, + ExploreRoute: ExploreRoute, ImportRoute: ImportRoute, LoadingRoute: LoadingRoute, MirrorsRoute: MirrorsRoute, diff --git a/desktop/src/routes/explore.tsx b/desktop/src/routes/explore.tsx new file mode 100644 index 00000000..c75b3299 --- /dev/null +++ b/desktop/src/routes/explore.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import ExplorerPage from "../pages/explorer"; + +export const Route = createFileRoute("/explore")({ + component: ExplorerPage, +});