From 374e1c8192176eca6f97623197c165b2c7d014f5 Mon Sep 17 00:00:00 2001 From: Lu Wilson Date: Tue, 23 Jun 2026 13:54:51 +0100 Subject: [PATCH 01/21] npm i --- package-lock.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/package-lock.json b/package-lock.json index b09983c..3a6669e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1295,6 +1295,7 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1344,6 +1345,7 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -1915,6 +1917,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2325,6 +2328,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2385,6 +2389,7 @@ "integrity": "sha512-EFNNzu4HqtTRb5DJINpyd+u3bDdzETWDMpCzG+UBHz1tpsnMDCeOcf61u4Wy/cbXnMymK+MT9bjH7KcG1fItSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -3263,6 +3268,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3410,6 +3416,7 @@ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -3592,6 +3599,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3644,6 +3652,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3759,6 +3768,7 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -3852,6 +3862,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3871,6 +3882,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", @@ -3929,6 +3941,7 @@ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, From 135a336a461d939be87e2611b9ad3135509b3c53 Mon Sep 17 00:00:00 2001 From: Lu Wilson Date: Tue, 23 Jun 2026 19:29:54 +0100 Subject: [PATCH 02/21] go --- src/prototypes/musical-group/BookmarkIcon.vue | 43 ++ .../musical-group/ImageCarousel.vue | 115 ++++++ .../MusicalGroupChromeHeader.vue | 109 +++++ .../musical-group/MusicalGroupFacts.vue | 104 +++++ .../musical-group/MusicalGroupHeader.vue | 162 ++++++++ .../musical-group/MusicalGroupScreen.vue | 49 +++ .../musical-group/MusicalGroupSplash.vue | 211 ++++++++++ .../musical-group/MusicalGroupTabs.vue | 97 +++++ .../musical-group/assets/bookmark-filled.svg | 8 + .../musical-group/assets/bookmark-outline.svg | 8 + .../musical-group/assets/wikipedia-w.svg | 6 + .../musical-group/data/bookmarks.ts | 39 ++ .../musical-group/data/commonsImages.ts | 376 ++++++++++++++++++ .../musical-group/data/fetchMusicalGroup.ts | 54 +++ .../musical-group/data/formatLabel.ts | 12 + .../musical-group/data/loadMusicalGroup.ts | 22 + .../musical-group/data/musicalGroupCache.ts | 165 ++++++++ src/prototypes/musical-group/data/types.ts | 35 ++ .../musical-group/data/wikidataApi.ts | 368 +++++++++++++++++ src/prototypes/musical-group/index.vue | 145 +++++++ 20 files changed, 2128 insertions(+) create mode 100644 src/prototypes/musical-group/BookmarkIcon.vue create mode 100644 src/prototypes/musical-group/ImageCarousel.vue create mode 100644 src/prototypes/musical-group/MusicalGroupChromeHeader.vue create mode 100644 src/prototypes/musical-group/MusicalGroupFacts.vue create mode 100644 src/prototypes/musical-group/MusicalGroupHeader.vue create mode 100644 src/prototypes/musical-group/MusicalGroupScreen.vue create mode 100644 src/prototypes/musical-group/MusicalGroupSplash.vue create mode 100644 src/prototypes/musical-group/MusicalGroupTabs.vue create mode 100644 src/prototypes/musical-group/assets/bookmark-filled.svg create mode 100644 src/prototypes/musical-group/assets/bookmark-outline.svg create mode 100644 src/prototypes/musical-group/assets/wikipedia-w.svg create mode 100644 src/prototypes/musical-group/data/bookmarks.ts create mode 100644 src/prototypes/musical-group/data/commonsImages.ts create mode 100644 src/prototypes/musical-group/data/fetchMusicalGroup.ts create mode 100644 src/prototypes/musical-group/data/formatLabel.ts create mode 100644 src/prototypes/musical-group/data/loadMusicalGroup.ts create mode 100644 src/prototypes/musical-group/data/musicalGroupCache.ts create mode 100644 src/prototypes/musical-group/data/types.ts create mode 100644 src/prototypes/musical-group/data/wikidataApi.ts create mode 100644 src/prototypes/musical-group/index.vue diff --git a/src/prototypes/musical-group/BookmarkIcon.vue b/src/prototypes/musical-group/BookmarkIcon.vue new file mode 100644 index 0000000..ed57065 --- /dev/null +++ b/src/prototypes/musical-group/BookmarkIcon.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/src/prototypes/musical-group/ImageCarousel.vue b/src/prototypes/musical-group/ImageCarousel.vue new file mode 100644 index 0000000..08b5fe0 --- /dev/null +++ b/src/prototypes/musical-group/ImageCarousel.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupChromeHeader.vue b/src/prototypes/musical-group/MusicalGroupChromeHeader.vue new file mode 100644 index 0000000..4d399f2 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupChromeHeader.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupFacts.vue b/src/prototypes/musical-group/MusicalGroupFacts.vue new file mode 100644 index 0000000..e1c32db --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupFacts.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupHeader.vue b/src/prototypes/musical-group/MusicalGroupHeader.vue new file mode 100644 index 0000000..c1c03a4 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupHeader.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupScreen.vue b/src/prototypes/musical-group/MusicalGroupScreen.vue new file mode 100644 index 0000000..fa978cc --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupScreen.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupSplash.vue b/src/prototypes/musical-group/MusicalGroupSplash.vue new file mode 100644 index 0000000..bd58e3f --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupSplash.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupTabs.vue b/src/prototypes/musical-group/MusicalGroupTabs.vue new file mode 100644 index 0000000..4135d06 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupTabs.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/prototypes/musical-group/assets/bookmark-filled.svg b/src/prototypes/musical-group/assets/bookmark-filled.svg new file mode 100644 index 0000000..0681c34 --- /dev/null +++ b/src/prototypes/musical-group/assets/bookmark-filled.svg @@ -0,0 +1,8 @@ + diff --git a/src/prototypes/musical-group/assets/bookmark-outline.svg b/src/prototypes/musical-group/assets/bookmark-outline.svg new file mode 100644 index 0000000..58adadf --- /dev/null +++ b/src/prototypes/musical-group/assets/bookmark-outline.svg @@ -0,0 +1,8 @@ + diff --git a/src/prototypes/musical-group/assets/wikipedia-w.svg b/src/prototypes/musical-group/assets/wikipedia-w.svg new file mode 100644 index 0000000..8717fda --- /dev/null +++ b/src/prototypes/musical-group/assets/wikipedia-w.svg @@ -0,0 +1,6 @@ + diff --git a/src/prototypes/musical-group/data/bookmarks.ts b/src/prototypes/musical-group/data/bookmarks.ts new file mode 100644 index 0000000..027d910 --- /dev/null +++ b/src/prototypes/musical-group/data/bookmarks.ts @@ -0,0 +1,39 @@ +const STORAGE_KEY = 'musical-group-bookmarks' + +function readBookmarks(): Set { + if (typeof window === 'undefined') return new Set() + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return new Set() + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return new Set() + return new Set(parsed.filter((id): id is string => typeof id === 'string')) + } catch { + return new Set() + } +} + +function writeBookmarks(bookmarks: Set): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...bookmarks])) + } catch { + // Ignore quota / private mode failures. + } +} + +export function isBookmarked(id: string): boolean { + return readBookmarks().has(id) +} + +export function toggleBookmark(id: string): boolean { + const bookmarks = readBookmarks() + if (bookmarks.has(id)) { + bookmarks.delete(id) + writeBookmarks(bookmarks) + return false + } + bookmarks.add(id) + writeBookmarks(bookmarks) + return true +} diff --git a/src/prototypes/musical-group/data/commonsImages.ts b/src/prototypes/musical-group/data/commonsImages.ts new file mode 100644 index 0000000..89ec995 --- /dev/null +++ b/src/prototypes/musical-group/data/commonsImages.ts @@ -0,0 +1,376 @@ +import type { CarouselImage, ImageOrientation } from './types' + +const COMMONS_API = 'https://commons.wikimedia.org/w/api.php' +const THUMB_WIDTH = 800 + +interface ImageDetails { + url: string + width: number + height: number +} + +interface CategoryBfsOptions { + maxDepth?: number + maxCategories?: number + maxFiles?: number + signal?: AbortSignal +} + +function normalizeFileTitle(title: string): string { + const trimmed = title.trim() + if (trimmed.startsWith('File:')) return trimmed + return `File:${trimmed.replace(/^File:/i, '')}` +} + +function stripFilePrefix(title: string): string { + return title.replace(/^File:/i, '') +} + +function classifyOrientation(width: number, height: number): ImageOrientation { + if (width <= 0 || height <= 0) return 'landscape' + const ratio = width / height + if (ratio >= 1.25) return 'landscape' + if (ratio >= 0.95 && ratio <= 1.05) return 'square' + if (ratio >= 0.55) return 'portrait' + return 'tall' +} + +async function commonsGet(params: Record, signal?: AbortSignal): Promise { + const url = new URL(COMMONS_API) + url.searchParams.set('format', 'json') + url.searchParams.set('origin', '*') + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + + const response = await fetch(url, { signal }) + if (!response.ok) { + throw new Error(`Commons API error: ${response.status}`) + } + return response.json() +} + +async function fetchImageDetails(titles: string[], signal?: AbortSignal): Promise> { + const map = new Map() + if (titles.length === 0) return map + + const data = (await commonsGet( + { + action: 'query', + titles: titles.join('|'), + prop: 'imageinfo', + iiprop: 'url|size', + iiurlwidth: String(THUMB_WIDTH), + }, + signal, + )) as { + query?: { + pages?: Record< + string, + { + title?: string + imageinfo?: Array<{ thumburl?: string; url?: string; thumbwidth?: number; thumbheight?: number }> + } + > + } + } + + for (const page of Object.values(data.query?.pages ?? {})) { + const info = page.imageinfo?.[0] + const url = info?.thumburl ?? info?.url + if (!page.title || !url) continue + map.set(normalizeFileTitle(page.title), { + url, + width: info?.thumbwidth ?? 0, + height: info?.thumbheight ?? 0, + }) + } + + return map +} + +async function searchCommonsFiles(query: string, signal?: AbortSignal): Promise { + const data = (await commonsGet( + { + action: 'query', + generator: 'search', + gsrnamespace: '6', + gsrsearch: query, + gsrlimit: '50', + prop: 'info', + }, + signal, + )) as { + query?: { + pages?: Record + } + } + + return Object.values(data.query?.pages ?? {}) + .map((page) => page.title) + .filter((title): title is string => Boolean(title)) + .map(normalizeFileTitle) +} + +/** Files in category and all nested subcategories, search-ranked (relevance order). */ +async function searchCommonsDeepCategory(categoryName: string, signal?: AbortSignal): Promise { + const data = (await commonsGet( + { + action: 'query', + generator: 'search', + gsrnamespace: '6', + gsrsearch: `deepcat:"${categoryName.replace(/"/g, '')}"`, + gsrlimit: '50', + prop: 'info', + }, + signal, + )) as { + query?: { + pages?: Record + } + } + + return Object.values(data.query?.pages ?? {}) + .map((page) => page.title) + .filter((title): title is string => Boolean(title)) + .map(normalizeFileTitle) +} + +async function fetchDirectCategoryFiles(categoryName: string, signal?: AbortSignal): Promise { + const data = (await commonsGet( + { + action: 'query', + list: 'categorymembers', + cmtitle: `Category:${categoryName}`, + cmtype: 'file', + cmlimit: '50', + }, + signal, + )) as { + query?: { + categorymembers?: Array<{ title?: string }> + } + } + + return (data.query?.categorymembers ?? []) + .map((member) => member.title) + .filter((title): title is string => Boolean(title)) + .map(normalizeFileTitle) +} + +async function fetchSubcategoryTitles(categoryName: string, signal?: AbortSignal): Promise { + const data = (await commonsGet( + { + action: 'query', + list: 'categorymembers', + cmtitle: `Category:${categoryName}`, + cmtype: 'subcat', + cmlimit: '50', + }, + signal, + )) as { + query?: { + categorymembers?: Array<{ title?: string }> + } + } + + return (data.query?.categorymembers ?? []) + .map((member) => member.title?.replace(/^Category:/i, '') ?? '') + .filter(Boolean) +} + +/** Walk nested subcategories and collect file titles (deduped, breadth-first). */ +async function fetchCategoryFilesBfs( + rootCategoryName: string, + options: CategoryBfsOptions = {}, +): Promise { + const { maxDepth = 5, maxCategories = 50, maxFiles = 100, signal } = options + const seenCategories = new Set() + const seenFiles = new Set() + const orderedFiles: string[] = [] + + type QueueItem = { name: string; depth: number } + const queue: QueueItem[] = [{ name: rootCategoryName, depth: 0 }] + + while (queue.length > 0 && seenCategories.size < maxCategories && orderedFiles.length < maxFiles) { + const item = queue.shift() + if (!item) break + + const key = item.name.toLowerCase() + if (seenCategories.has(key)) continue + seenCategories.add(key) + + const files = await fetchDirectCategoryFiles(item.name, signal) + for (const title of files) { + const norm = normalizeFileTitle(title) + if (seenFiles.has(norm)) continue + seenFiles.add(norm) + orderedFiles.push(norm) + if (orderedFiles.length >= maxFiles) break + } + + if (item.depth < maxDepth) { + const subcats = await fetchSubcategoryTitles(item.name, signal) + for (const sub of subcats) { + queue.push({ name: sub, depth: item.depth + 1 }) + } + } + } + + return orderedFiles +} + +/** deepcat list + set; BFS fallback if deepcat returns nothing. */ +async function buildCategoryMembership( + categoryName: string, + signal?: AbortSignal, +): Promise<{ ordered: string[]; set: Set }> { + let ordered: string[] = [] + try { + ordered = await searchCommonsDeepCategory(categoryName, signal) + } catch { + // fall through to BFS + } + + if (ordered.length === 0) { + ordered = await fetchCategoryFilesBfs(categoryName, { + maxDepth: 5, + maxCategories: 50, + maxFiles: 100, + signal, + }) + } else { + // Supplement deepcat with BFS files not already found (very deep nesting). + const set = new Set(ordered.map(normalizeFileTitle)) + const bfsExtra = await fetchCategoryFilesBfs(categoryName, { + maxDepth: 5, + maxCategories: 50, + maxFiles: 100, + signal, + }) + for (const title of bfsExtra) { + const norm = normalizeFileTitle(title) + if (!set.has(norm)) { + set.add(norm) + ordered.push(norm) + } + } + return { ordered, set } + } + + return { ordered, set: new Set(ordered.map(normalizeFileTitle)) } +} + +/** + * Pick carousel images: P18 first, then highest-relevance titles while + * preferring one of each orientation from the earliest (most relevant) match. + */ +function selectCarouselImages( + relevanceOrderedTitles: string[], + detailsMap: Map, + maxImages: number, +): CarouselImage[] { + const candidates: CarouselImage[] = [] + for (const title of relevanceOrderedTitles) { + const details = detailsMap.get(normalizeFileTitle(title)) + if (!details) continue + candidates.push({ + url: details.url, + orientation: classifyOrientation(details.width, details.height), + }) + } + + if (candidates.length === 0) return [] + if (candidates.length <= maxImages) return candidates + + const selected: CarouselImage[] = [] + const usedIndices = new Set() + + // P18 / first candidate always. + selected.push(candidates[0]) + usedIndices.add(0) + + const orientations: ImageOrientation[] = ['landscape', 'square', 'portrait', 'tall'] + for (const orientation of orientations) { + if (selected.length >= maxImages) break + const idx = candidates.findIndex((c, i) => !usedIndices.has(i) && c.orientation === orientation) + if (idx >= 0) { + selected.push(candidates[idx]) + usedIndices.add(idx) + } + } + + for (let i = 0; i < candidates.length && selected.length < maxImages; i++) { + if (usedIndices.has(i)) continue + selected.push(candidates[i]) + usedIndices.add(i) + } + + return selected +} + +export interface FetchCarouselImagesOptions { + imageFilename: string | null + commonsCategory: string | null + label: string + signal?: AbortSignal +} + +export async function fetchCarouselImages({ + imageFilename, + commonsCategory, + label, + signal, +}: FetchCarouselImagesOptions): Promise { + const seen = new Set() + const relevanceOrder: string[] = [] + + function queueTitle(title: string | null | undefined) { + if (!title) return + const normalized = normalizeFileTitle(title) + if (seen.has(normalized)) return + seen.add(normalized) + relevanceOrder.push(normalized) + } + + // 1. Wikidata P18 — always first. + queueTitle(imageFilename ? stripFilePrefix(imageFilename) : null) + + let categoryMembership: { ordered: string[]; set: Set } | null = null + + if (commonsCategory) { + categoryMembership = await buildCategoryMembership(commonsCategory, signal) + + // 2. Label search hits cross-checked against full category tree (incl. deep subcats). + if (label.trim()) { + const labelHits = await searchCommonsFiles(label, signal) + for (const title of labelHits) { + if (categoryMembership.set.has(normalizeFileTitle(title))) { + queueTitle(title) + } + } + } + + // 3. deepcat-ranked files not already queued. + for (const title of categoryMembership.ordered) { + queueTitle(title) + } + + // 4. Direct category members (may include files deepcat missed). + const directFiles = await fetchDirectCategoryFiles(commonsCategory, signal) + for (const title of directFiles) { + queueTitle(title) + } + } else if (label.trim()) { + // No Commons category — fall back to label search only. + const labelHits = await searchCommonsFiles(label, signal) + for (const title of labelHits) { + queueTitle(title) + } + } + + if (relevanceOrder.length === 0) return [] + + const detailsMap = await fetchImageDetails(relevanceOrder.slice(0, 30), signal) + return selectCarouselImages(relevanceOrder, detailsMap, 5) +} diff --git a/src/prototypes/musical-group/data/fetchMusicalGroup.ts b/src/prototypes/musical-group/data/fetchMusicalGroup.ts new file mode 100644 index 0000000..030eed0 --- /dev/null +++ b/src/prototypes/musical-group/data/fetchMusicalGroup.ts @@ -0,0 +1,54 @@ +import { fetchCarouselImages } from './commonsImages' +import { sentenceCase } from './formatLabel' +import type { FetchMusicalGroupOptions, MusicalGroupData, CarouselImage } from './types' +import { + fetchEditIndicator, + fetchEntityClaims, + isMusicalGroup, + resolveEntityLabels, + resolveMusicalTypeLabel, + websiteHost, +} from './wikidataApi' + +export async function fetchMusicalGroup( + id: string, + options: FetchMusicalGroupOptions = {}, +): Promise { + const { signal } = options + + const valid = await isMusicalGroup(id, signal) + if (!valid) { + throw new Error('Not a musical group') + } + + const claims = await fetchEntityClaims(id, signal) + + const [typeLabel, labelMap, editIndicator, images] = await Promise.all([ + resolveMusicalTypeLabel(id, claims.typeIds, signal), + resolveEntityLabels(claims.genreIds, signal), + fetchEditIndicator(id, signal).catch(() => undefined), + fetchCarouselImages({ + label: claims.label, + imageFilename: claims.imageFilename, + commonsCategory: claims.commonsCategory, + signal, + }).catch(() => [] as CarouselImage[]), + ]) + + const genres = claims.genreIds + .map((genreId) => labelMap.get(genreId)) + .filter((label): label is string => Boolean(label)) + + return { + id, + label: claims.label, + description: claims.description, + typeLabel: typeLabel ? sentenceCase(typeLabel) : undefined, + inceptionYear: claims.inceptionYear, + genres, + websiteUrl: claims.websiteUrl, + websiteHost: claims.websiteUrl ? websiteHost(claims.websiteUrl) : undefined, + images, + editIndicator, + } +} diff --git a/src/prototypes/musical-group/data/formatLabel.ts b/src/prototypes/musical-group/data/formatLabel.ts new file mode 100644 index 0000000..f5005ab --- /dev/null +++ b/src/prototypes/musical-group/data/formatLabel.ts @@ -0,0 +1,12 @@ +/** Capitalize the first character for reader-facing labels. */ +export function sentenceCase(text: string): string { + const trimmed = text.trim() + if (!trimmed.length) return trimmed + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1) +} + +/** Sentence-case a comma-separated list (first item only, per design). */ +export function sentenceCaseList(items: string[]): string { + const joined = items.join(', ') + return sentenceCase(joined) +} diff --git a/src/prototypes/musical-group/data/loadMusicalGroup.ts b/src/prototypes/musical-group/data/loadMusicalGroup.ts new file mode 100644 index 0000000..9ec1225 --- /dev/null +++ b/src/prototypes/musical-group/data/loadMusicalGroup.ts @@ -0,0 +1,22 @@ +import { fetchMusicalGroup } from './fetchMusicalGroup' +import { getCachedMusicalGroup, setCachedMusicalGroup } from './musicalGroupCache' +import type { FetchMusicalGroupOptions, MusicalGroupData } from './types' + +export interface LoadMusicalGroupResult { + data: MusicalGroupData + fromCache: boolean +} + +export async function loadMusicalGroup( + id: string, + options: FetchMusicalGroupOptions = {}, +): Promise { + const cached = getCachedMusicalGroup(id) + if (cached) { + return { data: cached.data, fromCache: true } + } + + const data = await fetchMusicalGroup(id, options) + setCachedMusicalGroup(id, data) + return { data, fromCache: false } +} diff --git a/src/prototypes/musical-group/data/musicalGroupCache.ts b/src/prototypes/musical-group/data/musicalGroupCache.ts new file mode 100644 index 0000000..bdd0384 --- /dev/null +++ b/src/prototypes/musical-group/data/musicalGroupCache.ts @@ -0,0 +1,165 @@ +import { normalizeQid } from './wikidataApi' +import type { CarouselImage, EditIndicator, MusicalGroupData } from './types' + +export const MUSICAL_GROUP_CACHE_VERSION = 7 + +const STORAGE_KEY = 'musical-group-page-cache' + +export interface CachedMusicalGroupEntry { + version: number + fetchedAt: number + data: MusicalGroupData +} + +type MusicalGroupCacheStore = Record + +function cacheKey(id: string): string { + return normalizeQid(id) ?? id.trim() +} + +function isEditIndicator(value: unknown): value is EditIndicator { + return value === 'history' || value === 'talk' +} + +function isCarouselImage(value: unknown): value is CarouselImage { + if (typeof value !== 'object' || value === null) return false + + const record = value as Record + return ( + typeof record.url === 'string' && + (record.orientation === 'landscape' || + record.orientation === 'square' || + record.orientation === 'portrait' || + record.orientation === 'tall') + ) +} + +function isMusicalGroupData(value: unknown): value is MusicalGroupData { + if (typeof value !== 'object' || value === null) return false + + const record = value as Record + if (typeof record.id !== 'string' || !record.id.length) return false + if (typeof record.label !== 'string' || !record.label.length) return false + if (!Array.isArray(record.genres) || !record.genres.every((g) => typeof g === 'string')) { + return false + } + if (!Array.isArray(record.images) || !record.images.every(isCarouselImage)) { + return false + } + + if (record.description !== undefined && typeof record.description !== 'string') return false + if (record.typeLabel !== undefined && typeof record.typeLabel !== 'string') return false + if (record.inceptionYear !== undefined && typeof record.inceptionYear !== 'number') return false + if (record.websiteUrl !== undefined && typeof record.websiteUrl !== 'string') return false + if (record.websiteHost !== undefined && typeof record.websiteHost !== 'string') return false + if (record.editIndicator !== undefined && !isEditIndicator(record.editIndicator)) return false + + return true +} + +function isValidEntry(entry: unknown): entry is CachedMusicalGroupEntry { + if (typeof entry !== 'object' || entry === null) return false + + const record = entry as CachedMusicalGroupEntry + return ( + record.version === MUSICAL_GROUP_CACHE_VERSION && + typeof record.fetchedAt === 'number' && + isMusicalGroupData(record.data) + ) +} + +function normalizeStore(raw: unknown): MusicalGroupCacheStore { + if (typeof raw !== 'object' || raw === null) return {} + + const store: MusicalGroupCacheStore = {} + + for (const [key, entry] of Object.entries(raw as Record)) { + if (!isValidEntry(entry)) continue + + const normalizedKey = cacheKey(entry.data.id) || cacheKey(key) + if (!normalizedKey.length) continue + + const existing = store[normalizedKey] + if (!existing || entry.fetchedAt >= existing.fetchedAt) { + store[normalizedKey] = entry + } + } + + return store +} + +function clearStoredCache(): void { + if (typeof window === 'undefined') return + + try { + window.localStorage.removeItem(STORAGE_KEY) + } catch { + // Private mode or blocked storage — ignore. + } +} + +function readRawStore(): { raw: unknown; corrupt: boolean } { + if (typeof window === 'undefined') return { raw: null, corrupt: false } + + try { + const stored = window.localStorage.getItem(STORAGE_KEY) + if (!stored) return { raw: null, corrupt: false } + return { raw: JSON.parse(stored), corrupt: false } + } catch { + return { raw: null, corrupt: true } + } +} + +function readStore(): MusicalGroupCacheStore { + const { raw, corrupt } = readRawStore() + if (corrupt) { + clearStoredCache() + return {} + } + if (raw === null) return {} + + const normalized = normalizeStore(raw) + if (JSON.stringify(normalized) !== JSON.stringify(raw)) { + persistStore(normalized) + } + return normalized +} + +function persistStore(store: MusicalGroupCacheStore): void { + if (typeof window === 'undefined') return + + const normalized = normalizeStore(store) + + try { + if (Object.keys(normalized).length === 0) { + clearStoredCache() + return + } + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)) + } catch { + // Quota or private-mode failures — ignore. + } +} + +export function getCachedMusicalGroup(id: string): CachedMusicalGroupEntry | null { + const key = cacheKey(id) + if (!key.length) return null + + const store = readStore() + return store[key] ?? null +} + +export function setCachedMusicalGroup(id: string, data: MusicalGroupData): CachedMusicalGroupEntry { + const key = cacheKey(id) || cacheKey(data.id) + const entry: CachedMusicalGroupEntry = { + version: MUSICAL_GROUP_CACHE_VERSION, + fetchedAt: Date.now(), + data, + } + + if (!key.length) return entry + + const store = readStore() + persistStore({ ...store, [key]: entry }) + return entry +} diff --git a/src/prototypes/musical-group/data/types.ts b/src/prototypes/musical-group/data/types.ts new file mode 100644 index 0000000..605d2ce --- /dev/null +++ b/src/prototypes/musical-group/data/types.ts @@ -0,0 +1,35 @@ +export const MUSICAL_GROUP_QID = 'Q215380' + +export type TabId = 'overview' | 'article' | 'photos' | 'links' | 'members' | 'awards' + +export type EditIndicator = 'history' | 'talk' + +export interface MusicalGroupSearchResult { + id: string + label: string + description?: string +} + +export type CarouselImageOrientation = 'landscape' | 'square' | 'portrait' | 'tall' + +export interface CarouselImage { + url: string + orientation: CarouselImageOrientation +} + +export interface MusicalGroupData { + id: string + label: string + description?: string + typeLabel?: string + inceptionYear?: number + genres: string[] + websiteUrl?: string + websiteHost?: string + images: CarouselImage[] + editIndicator?: EditIndicator +} + +export interface FetchMusicalGroupOptions { + signal?: AbortSignal +} diff --git a/src/prototypes/musical-group/data/wikidataApi.ts b/src/prototypes/musical-group/data/wikidataApi.ts new file mode 100644 index 0000000..45a1d0a --- /dev/null +++ b/src/prototypes/musical-group/data/wikidataApi.ts @@ -0,0 +1,368 @@ +import { wikimediaApiFetchHeaders } from '@/config' + +import { MUSICAL_GROUP_QID, type EditIndicator, type MusicalGroupSearchResult } from './types' + +const WIKIDATA_API = 'https://www.wikidata.org/w/api.php' +const WIKIDATA_SPARQL = 'https://query.wikidata.org/sparql' + +function actionUrl(params: Record): string { + const search = new URLSearchParams({ + format: 'json', + formatversion: '2', + origin: '*', + ...params, + }) + return `${WIKIDATA_API}?${search.toString()}` +} + +async function sparqlQuery(query: string, signal?: AbortSignal): Promise { + const url = `${WIKIDATA_SPARQL}?query=${encodeURIComponent(query)}` + const response = await fetch(url, { + signal, + headers: { + Accept: 'application/sparql-results+json', + ...wikimediaApiFetchHeaders('musical-group-sparql'), + }, + }) + if (!response.ok) { + throw new Error(`SPARQL request failed (${response.status})`) + } + return response.json() as Promise +} + +export function normalizeQid(raw: unknown): string | null { + if (typeof raw !== 'string') return null + const trimmed = raw.trim() + const match = trimmed.match(/^Q(\d+)$/i) + if (!match) return null + return `Q${match[1]}` +} + +export function parseQidInput(raw: string): string | null { + const trimmed = raw.trim() + const direct = normalizeQid(trimmed) + if (direct) return direct + const urlMatch = trimmed.match(/wikidata\.org\/wiki\/(Q\d+)/i) + if (urlMatch) return normalizeQid(urlMatch[1]) + return null +} + +export async function isMusicalGroup(id: string, signal?: AbortSignal): Promise { + const query = ` +ASK { + wd:${id} wdt:P31/wdt:P279* wd:${MUSICAL_GROUP_QID} . +}` + const data = await sparqlQuery<{ boolean: boolean }>(query, signal) + return Boolean(data.boolean) +} + +export async function searchMusicalGroups( + searchText: string, + signal?: AbortSignal, +): Promise { + const query = searchText.trim() + if (!query.length) return [] + + const escaped = query.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + const sparql = ` +SELECT ?item ?itemLabel ?itemDescription WHERE { + SERVICE wikibase:mwapi { + bd:serviceParam wikibase:endpoint "www.wikidata.org"; + wikibase:api "EntitySearch"; + mwapi:search "${escaped}"; + mwapi:language "en". + ?item wikibase:apiOutputItem "@id". + } + ?item wdt:P31/wdt:P279* wd:${MUSICAL_GROUP_QID} . + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +LIMIT 8` + + interface SparqlRow { + item: { value: string } + itemLabel: { value: string } + itemDescription?: { value: string } + } + + const data = await sparqlQuery<{ results: { bindings: SparqlRow[] } }>(sparql, signal) + return data.results.bindings.map((row) => { + const id = row.item.value.replace(/^.*\//, '') + return { + id, + label: row.itemLabel.value, + description: row.itemDescription?.value, + } + }) +} + +interface WbEntityClaim { + mainsnak: { + datavalue?: { + type: string + value: string | { id?: string; time?: string; text?: string } + } + } +} + +interface WbEntity { + id?: string + labels?: Record + descriptions?: Record + claims?: Record +} + +interface WbGetEntitiesResponse { + entities?: Record +} + +export interface ParsedEntityClaims { + label: string + description?: string + imageFilename?: string + commonsCategory?: string + websiteUrl?: string + inceptionYear?: number + genreIds: string[] + typeIds: string[] +} + +function claimEntityId(claim: WbEntityClaim): string | null { + const value = claim.mainsnak.datavalue?.value + if (typeof value === 'object' && value && 'id' in value && value.id) { + return value.id + } + return null +} + +function claimStringValue(claim: WbEntityClaim): string | null { + const value = claim.mainsnak.datavalue?.value + if (typeof value === 'string') return value + if (typeof value === 'object' && value && 'text' in value && value.text) { + return value.text + } + return null +} + +function claimTimeYear(claim: WbEntityClaim): number | null { + const value = claim.mainsnak.datavalue?.value + if (typeof value !== 'object' || !value || !('time' in value) || !value.time) return null + const match = value.time.match(/^\+?(-?\d{4})/) + if (!match) return null + return Number.parseInt(match[1], 10) +} + +function firstClaimString(claims: WbEntityClaim[] | undefined): string | undefined { + if (!claims?.length) return undefined + for (const claim of claims) { + const value = claimStringValue(claim) + if (value) return value + } + return undefined +} + +function allClaimEntityIds(claims: WbEntityClaim[] | undefined): string[] { + if (!claims?.length) return [] + const ids: string[] = [] + for (const claim of claims) { + const id = claimEntityId(claim) + if (id) ids.push(id) + } + return ids +} + +export async function fetchEntityClaims( + id: string, + signal?: AbortSignal, +): Promise { + const url = actionUrl({ + action: 'wbgetentities', + ids: id, + props: 'labels|descriptions|claims', + languages: 'en', + languagefallback: '1', + }) + + const response = await fetch(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wbgetentities'), + }) + if (!response.ok) { + throw new Error(`wbgetentities failed (${response.status})`) + } + + const data = (await response.json()) as WbGetEntitiesResponse + const entity = data.entities?.[id] + if (!entity) { + throw new Error(`Entity ${id} not found`) + } + + const label = + entity.labels?.en?.value ?? + Object.values(entity.labels ?? {})[0]?.value ?? + id + const description = + entity.descriptions?.en?.value ?? Object.values(entity.descriptions ?? {})[0]?.value + + const claims = entity.claims ?? {} + const inceptionYear = claims.P571?.length ? claimTimeYear(claims.P571[0]) : null + + return { + label, + description, + imageFilename: firstClaimString(claims.P18), + commonsCategory: firstClaimString(claims.P373), + websiteUrl: firstClaimString(claims.P856), + inceptionYear: inceptionYear ?? undefined, + genreIds: allClaimEntityIds(claims.P136), + typeIds: allClaimEntityIds(claims.P31), + } +} + +export async function resolveEntityLabels( + ids: string[], + signal?: AbortSignal, +): Promise> { + const unique = [...new Set(ids.filter(Boolean))] + const labels = new Map() + if (!unique.length) return labels + + const url = actionUrl({ + action: 'wbgetentities', + ids: unique.join('|'), + props: 'labels', + languages: 'en', + languagefallback: '1', + }) + + const response = await fetch(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-wbgetentities-labels'), + }) + if (!response.ok) { + throw new Error(`wbgetentities labels failed (${response.status})`) + } + + const data = (await response.json()) as WbGetEntitiesResponse + for (const entityId of unique) { + const entity = data.entities?.[entityId] + const label = + entity?.labels?.en?.value ?? + Object.values(entity?.labels ?? {})[0]?.value ?? + entityId + labels.set(entityId, label) + } + return labels +} + +export async function resolveMusicalTypeLabel( + entityId: string, + typeIds: string[], + signal?: AbortSignal, +): Promise { + if (!typeIds.length) return undefined + + const sparql = ` +SELECT ?type ?typeLabel WHERE { + wd:${entityId} wdt:P31 ?type . + ?type wdt:P279* wd:${MUSICAL_GROUP_QID} . + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +}` + + interface TypeRow { + type: { value: string } + typeLabel: { value: string } + } + + const data = await sparqlQuery<{ results: { bindings: TypeRow[] } }>(sparql, signal) + const musicalTypes = data.results.bindings.map((row) => ({ + id: row.type.value.replace(/^.*\//, ''), + label: row.typeLabel.value, + })) + + if (!musicalTypes.length) return undefined + + musicalTypes.sort((a, b) => b.label.length - a.label.length) + return musicalTypes[0].label +} + +export async function fetchEditIndicator( + id: string, + signal?: AbortSignal, +): Promise { + const url = actionUrl({ + action: 'query', + prop: 'revisions', + rvprop: 'timestamp', + rvlimit: '1', + titles: `${id}|Talk:${id}`, + }) + + const response = await fetch(url, { + signal, + headers: wikimediaApiFetchHeaders('musical-group-revisions'), + }) + if (!response.ok) { + throw new Error(`revisions query failed (${response.status})`) + } + + interface RevisionPage { + title: string + revisions?: { timestamp?: string }[] + } + + interface RevisionsResponse { + query?: { pages?: RevisionPage[] } + } + + const data = (await response.json()) as RevisionsResponse + const pages = data.query?.pages ?? [] + + let itemTimestamp: string | undefined + let talkTimestamp: string | undefined + + for (const page of pages) { + const timestamp = page.revisions?.[0]?.timestamp + if (!timestamp) continue + if (page.title === id) itemTimestamp = timestamp + if (page.title === `Talk:${id}`) talkTimestamp = timestamp + } + + if (!itemTimestamp && !talkTimestamp) return undefined + if (!itemTimestamp) return 'talk' + if (!talkTimestamp) return 'history' + + return talkTimestamp > itemTimestamp ? 'talk' : 'history' +} + +export function commonsFileUrl(filename: string, width = 640): string { + const name = filename.replace(/^File:/i, '').trim().replace(/ /g, '_') + return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(name)}?width=${width}` +} + +export function normalizeFileTitle(title: string): string { + return title.replace(/^File:/i, '').trim().toLowerCase() +} + +export function fileTitleFromInput(title: string): string { + return title.replace(/^File:/i, '').trim() +} + +export function websiteHost(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url.replace(/^https?:\/\//, '').replace(/\/.*$/, '') + } +} + +export function wikidataHistoryUrl(id: string): string { + return `https://www.wikidata.org/w/index.php?title=${encodeURIComponent(id)}&action=history` +} + +export function wikidataTalkHistoryUrl(id: string): string { + return `https://www.wikidata.org/w/index.php?title=${encodeURIComponent(`Talk:${id}`)}&action=history` +} + +export function wikidataEditEntityUrl(id: string): string { + return `https://www.wikidata.org/wiki/Special:EditEntity/${encodeURIComponent(id)}` +} diff --git a/src/prototypes/musical-group/index.vue b/src/prototypes/musical-group/index.vue new file mode 100644 index 0000000..8563cf8 --- /dev/null +++ b/src/prototypes/musical-group/index.vue @@ -0,0 +1,145 @@ + + + + + From a9fb65225a1384ce3b01fb37d8aad0e089b073d4 Mon Sep 17 00:00:00 2001 From: Lu Wilson Date: Wed, 24 Jun 2026 11:14:58 +0100 Subject: [PATCH 03/21] tabs update --- .../HardShadowTabTrack.vue | 256 ++++++++++++++++++ .../OptionSection.vue | 131 +++++++++ .../example-hard-shadow-border/index.vue | 232 ++++++++++++++++ .../musical-group/MusicalGroupTabs.vue | 6 +- 4 files changed, 623 insertions(+), 2 deletions(-) create mode 100644 src/prototypes/example-hard-shadow-border/HardShadowTabTrack.vue create mode 100644 src/prototypes/example-hard-shadow-border/OptionSection.vue create mode 100644 src/prototypes/example-hard-shadow-border/index.vue diff --git a/src/prototypes/example-hard-shadow-border/HardShadowTabTrack.vue b/src/prototypes/example-hard-shadow-border/HardShadowTabTrack.vue new file mode 100644 index 0000000..a9008af --- /dev/null +++ b/src/prototypes/example-hard-shadow-border/HardShadowTabTrack.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/src/prototypes/example-hard-shadow-border/OptionSection.vue b/src/prototypes/example-hard-shadow-border/OptionSection.vue new file mode 100644 index 0000000..37f20ee --- /dev/null +++ b/src/prototypes/example-hard-shadow-border/OptionSection.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/prototypes/example-hard-shadow-border/index.vue b/src/prototypes/example-hard-shadow-border/index.vue new file mode 100644 index 0000000..5dca623 --- /dev/null +++ b/src/prototypes/example-hard-shadow-border/index.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupTabs.vue b/src/prototypes/musical-group/MusicalGroupTabs.vue index 4135d06..0a535f9 100644 --- a/src/prototypes/musical-group/MusicalGroupTabs.vue +++ b/src/prototypes/musical-group/MusicalGroupTabs.vue @@ -68,13 +68,15 @@ const activeTab = ref('overview') height: 38px; padding: 1px var(--spacing-100); border: 1px solid var(--color-base); + border-bottom-width: 2px; + border-right-width: 2px; border-radius: 6px; background-color: var(--background-color-base); color: var(--color-base); font-family: var(--font-family-base); - font-size: var(--font-size-small); + font-size: var(--font-size-medium); font-weight: var(--font-weight-bold); - line-height: var(--line-height-small); + line-height: var(--line-height-medium); white-space: nowrap; cursor: pointer; } From 2c4f3ff120bc324b9f878f6814d350a5db0caf62 Mon Sep 17 00:00:00 2001 From: Lu Wilson Date: Wed, 24 Jun 2026 12:17:49 +0100 Subject: [PATCH 04/21] overview --- .../musical-group/MusicalGroupFacts.vue | 3 +- .../musical-group/MusicalGroupOverview.vue | 36 ++ .../MusicalGroupOverviewArticleCard.vue | 142 +++++++ .../MusicalGroupOverviewPhotosCard.vue | 78 ++++ .../musical-group/MusicalGroupScreen.vue | 29 +- .../musical-group/MusicalGroupTabs.vue | 4 +- .../musical-group/OverviewSummaryCard.vue | 91 +++++ .../musical-group/data/carouselLayout.ts | 47 +++ .../musical-group/data/commonsImages.ts | 363 ++++++++++++------ .../musical-group/data/fetchMusicalGroup.ts | 34 +- .../data/fetchMusicalGroupOverview.ts | 292 ++++++++++++++ .../musical-group/data/loadMusicalGroup.ts | 4 +- .../data/loadMusicalGroupOverview.ts | 64 +++ .../musical-group/data/musicalGroupCache.ts | 107 +++++- src/prototypes/musical-group/data/types.ts | 33 ++ .../musical-group/data/wikidataApi.ts | 5 +- src/prototypes/musical-group/index.vue | 55 ++- 17 files changed, 1234 insertions(+), 153 deletions(-) create mode 100644 src/prototypes/musical-group/MusicalGroupOverview.vue create mode 100644 src/prototypes/musical-group/MusicalGroupOverviewArticleCard.vue create mode 100644 src/prototypes/musical-group/MusicalGroupOverviewPhotosCard.vue create mode 100644 src/prototypes/musical-group/OverviewSummaryCard.vue create mode 100644 src/prototypes/musical-group/data/carouselLayout.ts create mode 100644 src/prototypes/musical-group/data/fetchMusicalGroupOverview.ts create mode 100644 src/prototypes/musical-group/data/loadMusicalGroupOverview.ts diff --git a/src/prototypes/musical-group/MusicalGroupFacts.vue b/src/prototypes/musical-group/MusicalGroupFacts.vue index e1c32db..ed9db43 100644 --- a/src/prototypes/musical-group/MusicalGroupFacts.vue +++ b/src/prototypes/musical-group/MusicalGroupFacts.vue @@ -32,7 +32,7 @@ const genreLine = computed(() =>

{{ primaryFact }}

-

{{ genreLine }}

+ {{ genreLine }}

@@ -72,7 +72,6 @@ const genreLine = computed(() => } .musical-group-facts__genres { - margin: 0; color: var(--color-subtle); } diff --git a/src/prototypes/musical-group/MusicalGroupOverview.vue b/src/prototypes/musical-group/MusicalGroupOverview.vue new file mode 100644 index 0000000..f577c4c --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverview.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupOverviewArticleCard.vue b/src/prototypes/musical-group/MusicalGroupOverviewArticleCard.vue new file mode 100644 index 0000000..69f7bd0 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverviewArticleCard.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupOverviewPhotosCard.vue b/src/prototypes/musical-group/MusicalGroupOverviewPhotosCard.vue new file mode 100644 index 0000000..47cb5b4 --- /dev/null +++ b/src/prototypes/musical-group/MusicalGroupOverviewPhotosCard.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/prototypes/musical-group/MusicalGroupScreen.vue b/src/prototypes/musical-group/MusicalGroupScreen.vue index fa978cc..690da94 100644 --- a/src/prototypes/musical-group/MusicalGroupScreen.vue +++ b/src/prototypes/musical-group/MusicalGroupScreen.vue @@ -1,16 +1,23 @@ @@ -46,4 +62,13 @@ defineProps() flex-direction: column; gap: var(--spacing-100); } + +.musical-group-screen__panel { + min-width: 0; +} + +.musical-group-screen__placeholder { + margin: 0; + color: var(--color-subtle); +} diff --git a/src/prototypes/musical-group/MusicalGroupTabs.vue b/src/prototypes/musical-group/MusicalGroupTabs.vue index 0a535f9..45f177c 100644 --- a/src/prototypes/musical-group/MusicalGroupTabs.vue +++ b/src/prototypes/musical-group/MusicalGroupTabs.vue @@ -1,6 +1,4 @@ From d0a40c9f702bd3c7b0432aedb20d92e84b422aa6 Mon Sep 17 00:00:00 2001 From: Lu Wilson Date: Wed, 24 Jun 2026 18:42:26 +0100 Subject: [PATCH 05/21] LETS GO --- .../MusicalGroupChromeHeader.vue | 3 - .../musical-group/MusicalGroupHeader.vue | 150 +-------------- .../musical-group/MusicalGroupOverview.vue | 2 +- .../musical-group/MusicalGroupScreen.vue | 33 ++-- .../musical-group/MusicalGroupTabs.vue | 85 ++++++--- .../musical-group/MusicalGroupTitleRow.vue | 171 ++++++++++++++++++ src/prototypes/musical-group/index.vue | 60 +++++- .../useMusicalGroupScrollStates.ts | 95 ++++++++++ 8 files changed, 414 insertions(+), 185 deletions(-) create mode 100644 src/prototypes/musical-group/MusicalGroupTitleRow.vue create mode 100644 src/prototypes/musical-group/useMusicalGroupScrollStates.ts diff --git a/src/prototypes/musical-group/MusicalGroupChromeHeader.vue b/src/prototypes/musical-group/MusicalGroupChromeHeader.vue index 4d399f2..279870d 100644 --- a/src/prototypes/musical-group/MusicalGroupChromeHeader.vue +++ b/src/prototypes/musical-group/MusicalGroupChromeHeader.vue @@ -47,9 +47,6 @@ import { diff --git a/src/prototypes/musical-group/MusicalGroupScreen.vue b/src/prototypes/musical-group/MusicalGroupScreen.vue index 690da94..a27e70d 100644 --- a/src/prototypes/musical-group/MusicalGroupScreen.vue +++ b/src/prototypes/musical-group/MusicalGroupScreen.vue @@ -7,6 +7,7 @@ import MusicalGroupHeader from './MusicalGroupHeader.vue' import MusicalGroupOverview from './MusicalGroupOverview.vue' import MusicalGroupTabs from './MusicalGroupTabs.vue' import type { MusicalGroupData, MusicalGroupOverviewData, TabId } from './data/types' +import { useMusicalGroupScrollStates } from './useMusicalGroupScrollStates' interface Props { data: MusicalGroupData @@ -18,6 +19,8 @@ interface Props { defineProps() const activeTab = ref('overview') + +useMusicalGroupScrollStates()