[fix] solution: 엽서·가요 다방 손질 — 재굽기 전 사이트도 공유되게 사진을 중계한다

배포 뒤 대표 지적 넷을 한 번에 고친다.

- 사진 중계(`backend/router/v1/media/relay.py`, `/v1/image/relay`) — 굽는 쪽이 사진을
  우리 자리로 옮기는 건 **다시 굽는 사이트에만** 적용된다. 아직 재발행 안 한 사이트는
  캔버스가 오염된 채라 저장·공유가 막혀 있다. 같은 오리진으로 바이트를 흘려보내 그 사이를
  메운다. `postcard-canvas.loadImage` 가 CORS 실패 때만 두 번째로 탄다 —
  이미 우리 자리에 있는 사진은 첫 시도에서 끝난다.
  ★ 열린 프록시가 되지 않게: https · 호스트 allowlist · 이미지 타입 · 8MB ·
    리다이렉트 후 호스트 재검사 · IP 주소 거절. 실측: pstatic 사진 158KB 통과,
    http·목록 밖·127.0.0.1·169.254.169.254 전부 거절
- 엽서 미리보기 360px (320 → 240 은 작다 하셔서 그 사이로)
- 엽서 사진 목록에 슬라이더가 없던 것 — `.slider-viewport` 마크업만 손으로 찍어서
  embla 가 안 붙어 있었다(그건 스크립트 붙기 전의 기본 상태다). 진짜 `Carousel` 로 바꾼다
- 가요 다방 판이 전부 주朱색이던 것 — **실측 67곡 중 63곡에 `labelColor` 가 없다.**
  모델이 공통 규칙 2("확인 안 된 값은 뺀다")를 색에까지 적용한다. 색은 사실이 아니라
  디자인 값이다: 프롬프트에 그렇게 못 박고, 비었을 때는 제목 해시로 레코드 라벨색 8종에서
  고른다(무작위가 아니다 — 하이드레이션이 어긋나면 안 되고, 새로고침마다 바뀌어도 안 된다)

tsc·eslint 통과(site), site 79 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-15 09:51:53 +09:00
parent 6b9e01d876
commit f0c4d5f413
8 changed files with 174 additions and 24 deletions

View File

@ -50,7 +50,11 @@
→ 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다. → 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다.
→ 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다. → 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다.
→ ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다 → ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다
(아래 항목). (아래 항목). 그 사이를 메우는 건 **중계**다 — `/v1/image/relay?url=…`
(`backend/router/v1/media/relay.py`). 캔버스가 CORS 로 사진을 못 받으면 같은 오리진의
이 주소로 한 번 더 받아 본다(`site/src/lib/postcard-canvas.ts` `loadImage`).
열린 프록시가 아니다 — https · 호스트 allowlist · 이미지 타입 · 8MB · 리다이렉트 후
호스트 재검사. **호스트를 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는가" 를 먼저 본다.**
→ `originUrl` · `sourceType` 은 손대지 않는다 — 재게시 권리(DECISIONS 1-2)가 "불가" 로 → `originUrl` · `sourceType` 은 손대지 않는다 — 재게시 권리(DECISIONS 1-2)가 "불가" 로
결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다. 결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다.
- **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.** - **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.**

View File

@ -15,6 +15,7 @@ import router.v1.place.place
import router.v1.fact.fact import router.v1.fact.fact
import router.v1.faq.faq import router.v1.faq.faq
import router.v1.media.media import router.v1.media.media
import router.v1.media.relay
import router.v1.job.job import router.v1.job.job
import router.v1.site.site import router.v1.site.site
import router.v1.site.showcase import router.v1.site.showcase
@ -86,6 +87,9 @@ app.include_router(router.v1.place.place.router)
app.include_router(router.v1.fact.fact.router) app.include_router(router.v1.fact.fact.router)
app.include_router(router.v1.faq.faq.router) app.include_router(router.v1.faq.faq.router)
app.include_router(router.v1.media.media.router) app.include_router(router.v1.media.media.router)
# ★ 인증 없는 공개 중계. 발행본(정적 페이지)이 캔버스에 사진을 그릴 때 부른다 —
# 남의 CDN 이 CORS 를 안 줘서 캔버스가 오염되는 것을 피하는 유일한 길이다(relay.py).
app.include_router(router.v1.media.relay.router)
app.include_router(router.v1.job.job.router) app.include_router(router.v1.job.job.router)
app.include_router(router.v1.site.site.router) app.include_router(router.v1.site.site.router)
app.include_router(router.v1.site.site.my_router) app.include_router(router.v1.site.site.my_router)

View File

@ -0,0 +1,87 @@
"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다.
★ 왜 필요한가 (2026-09-15, 실측)
수집한 사진은 `*.pstatic.net` · `tong.visitkorea.or.kr` 에 있고 그쪽은
`Access-Control-Allow-Origin` 을 주지 않는다. 그 사진을 캔버스에 그리면 캔버스가 **오염돼**
`toBlob` 이 막힌다 — 엽서 쓰기의 저장·공유가 죽는다. 브라우저 정책이라 클라이언트에서는
못 넘는다. CORS 없는 `fetch` 도 같은 벽이다. **같은 오리진에서 바이트가 와야** 풀린다.
★ 굽는 쪽(`site/scripts/prerender.ts` mirrorMedia)이 이미 사진을 내려받아 사이트 폴더에
놓는다. 그게 근본이다. 다만 그건 **다시 굽는 사이트에만** 적용된다 — 이미 나가 있는
사이트는 사장님이 재발행할 때까지 옛 주소를 문다. 이 중계가 그 사이를 메운다.
★ 열린 프록시가 되지 않게 좁혀 둔다. 이건 "아무 주소나 가져다주는 통로" 가 아니다:
· https 만
· 호스트가 `_ALLOWED_SUFFIXES` 에 있는 것만 (우리 수집기가 쓰는 사진 CDN)
· 응답이 이미지가 아니면 거절, 크기 상한
· 리다이렉트를 따라가되 최종 호스트도 다시 검사한다 — 안 그러면 allowlist 를 우회한다
"""
import ipaddress
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, HTTPException, Query, Response
from common.logger import LOG
router = APIRouter(prefix="/v1/image", tags=["Image"])
# 우리 수집기가 사진을 가져오는 곳. 여기 없는 호스트는 중계하지 않는다.
# ★ 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는 곳인가" 를 먼저 본다.
_ALLOWED_SUFFIXES = (
".pstatic.net",
"tong.visitkorea.or.kr",
".visitkorea.or.kr",
"upload.wikimedia.org",
)
_MAX_BYTES = 8 * 1024 * 1024
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
# 기본 UA 를 거절하는 CDN 이 있다. 탐지 우회가 아니라 평범한 브라우저로 보이게 하는 것뿐이다.
_HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"}
def _allowed(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme != "https" or not parsed.hostname:
return False
host = parsed.hostname.lower()
# 숫자 주소는 받지 않는다 — 사내망으로 향하는 통로를 만들지 않는다.
try:
ipaddress.ip_address(host)
return False
except ValueError:
pass
return any(host == suffix.lstrip(".") or host.endswith(suffix) for suffix in _ALLOWED_SUFFIXES)
@router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다")
async def relay(url: str = Query(min_length=8, max_length=2000)):
"""인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는
것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다)."""
if not _allowed(url):
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
try:
async with httpx.AsyncClient(timeout=_TIMEOUT, headers=_HEADERS, follow_redirects=True) as client:
res = await client.get(url)
except httpx.HTTPError as ex:
LOG.w(f"[image] 중계 실패 {url}: {ex}")
raise HTTPException(status_code=502, detail="사진을 가져오지 못했습니다")
# 리다이렉트로 allowlist 밖으로 나갔으면 거기서 끊는다.
if not _allowed(str(res.url)):
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
kind = (res.headers.get("content-type") or "").split(";")[0].strip().lower()
if res.status_code != 200 or not kind.startswith("image/"):
raise HTTPException(status_code=502, detail="사진이 아닙니다")
if len(res.content) == 0 or len(res.content) > _MAX_BYTES:
raise HTTPException(status_code=502, detail="사진 크기가 범위를 벗어났습니다")
# 주소가 곧 내용이다(원본이 바뀌면 주소도 바뀐다) — 길게 캐시해서 중계 횟수를 줄인다.
return Response(
content=res.content,
media_type=kind,
headers={"cache-control": "public, max-age=604800, immutable"},
)

View File

@ -7,7 +7,7 @@
"label": "가요 다방", "label": "가요 다방",
"maxItems": 8, "maxItems": 8,
"task": "[해야 할 일]\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\n\n[스키마]\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", "task": "[해야 할 일]\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\n\n[스키마]\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n" "rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n **이건 사실이 아니라 디자인 값이다 — 공통 규칙 2 를 적용하지 말고 곡마다 반드시 채운다.**\n 앞뒤 곡과 같은 색을 쓰지 않는다. 비워 두면 판이 전부 한 색으로 깔린다(실측 2026-09-15:\n 67곡 중 63곡이 비어 있었다).\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n"
}, },
"daily": { "daily": {
"kind": "daily", "kind": "daily",

View File

@ -92,6 +92,9 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
[이 아이템만의 규칙] [이 아이템만의 규칙]
· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다. · lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.
· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다. · labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.
**이건 사실이 아니라 디자인 값이다 — 공통 규칙 2 를 적용하지 말고 곡마다 반드시 채운다.**
앞뒤 곡과 같은 색을 쓰지 않는다. 비워 두면 판이 전부 한 색으로 깔린다(실측 2026-09-15:
67곡 중 63곡이 비어 있었다).
· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다. · 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다.
`, `,
}, },

View File

@ -112,22 +112,39 @@ function stampLines(ctx: CanvasRenderingContext2D, name: string, maxWidth: numbe
* **CORS 없이 다시 받아 미리보기만** 세운다(저장·공유는 부르는 쪽이 막는다). * **CORS 없이 다시 받아 미리보기만** 세운다(저장·공유는 부르는 쪽이 막는다).
* ★ **2026-09-15 에 근본을 고쳤다** — 굽는 쪽이 사진을 내려받아 `s/<slug>/img/` 에 두고 * ★ **2026-09-15 에 근본을 고쳤다** — 굽는 쪽이 사진을 내려받아 `s/<slug>/img/` 에 두고
* payload 의 주소를 우리 오리진으로 바꾼다(`scripts/prerender.ts` `mirrorMedia`). * payload 의 주소를 우리 오리진으로 바꾼다(`scripts/prerender.ts` `mirrorMedia`).
* 같은 오리진이면 오염되지 않아 저장·공유가 열린다. 아래 폴백은 **못 받아 온 사진** * 같은 오리진이면 오염되지 않는다. **다만 그건 다시 굽는 사이트에만 적용된다** —
* (CDN 이 거절했거나 타입이 이상한 것)만을 위한 것이라 이제 드물게 탄다. * 이미 나가 있는 사이트는 사장님이 재발행할 때까지 옛 주소를 문다.
* ★ 그 사이를 **중계**가 메운다(`backend/router/v1/media/relay.py`). 두 번째 시도가 그것이다:
* 같은 오리진의 `/v1/image/relay?url=…` 로 바이트를 받아 오면 브라우저는 그걸 우리 사진으로
* 본다. 세 번째(맨손 로드)는 중계까지 막혔을 때의 미리보기 전용이다.
* ★ 순서를 바꾸지 않는다 — 이미 우리 자리에 있는 사진은 첫 시도에서 끝나야 한다.
* 중계를 먼저 걸면 우리 사진까지 서버를 한 번 더 거친다.
*/ */
const RELAY = '/v1/image/relay?url=';
async function loadImage(url: string): Promise<{img: HTMLImageElement; exportable: boolean}> { async function loadImage(url: string): Promise<{img: HTMLImageElement; exportable: boolean}> {
const tryLoad = (crossOrigin: string | null) => new Promise<HTMLImageElement>((resolve, reject) => { const tryLoad = (src: string, crossOrigin: string | null) =>
const img = new Image(); new Promise<HTMLImageElement>((resolve, reject) => {
if (crossOrigin) img.crossOrigin = crossOrigin; const img = new Image();
img.onload = () => resolve(img); if (crossOrigin) img.crossOrigin = crossOrigin;
img.onerror = reject; img.onload = () => resolve(img);
img.src = url; img.onerror = reject;
}); img.src = src;
});
try { try {
return {img: await tryLoad('anonymous'), exportable: true}; return {img: await tryLoad(url, 'anonymous'), exportable: true};
} catch { } catch {
return {img: await tryLoad(null), exportable: false}; // 남의 CDN 이 CORS 를 안 줬다 — 우리 서버를 거쳐 같은 오리진으로 받아 본다.
} }
if (/^https?:\/\//i.test(url)) {
try {
return {img: await tryLoad(`${RELAY}${encodeURIComponent(url)}`, 'anonymous'), exportable: true};
} catch {
// 중계가 없는 환경(정적 호스팅만 있는 미리보기)이거나 allowlist 밖이다.
}
}
return {img: await tryLoad(url, null), exportable: false};
} }
/** 이 사이트가 쓰는 세리프를 그대로 쓴다 — 화면의 카드와 그림의 글씨가 갈리지 않게. */ /** 이 사이트가 쓰는 세리프를 그대로 쓴다 — 화면의 카드와 그림의 글씨가 갈리지 않게. */

View File

@ -11,7 +11,7 @@
*/ */
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {Section} from '@site/lib/ui'; import {Carousel, Section} from '@site/lib/ui';
import {MAX_LINES, POSTCARD_SIZE, drawPostcard, textColumnWidth, wrapLines} from '@site/lib/postcard-canvas'; import {MAX_LINES, POSTCARD_SIZE, drawPostcard, textColumnWidth, wrapLines} from '@site/lib/postcard-canvas';
const FILE_NAME = 'postcard.png'; const FILE_NAME = 'postcard.png';
@ -114,18 +114,23 @@ export function PostcardMakerSection() {
{/* ★ 폭을 묶어 둔다 (2026-09-15 대표: "엽서 ui 너무 큼"). 엽서는 1:1 이라 열 폭을 {/* ★ 폭을 묶어 둔다 (2026-09-15 대표: "엽서 ui 너무 큼"). 엽서는 1:1 이라 열 폭을
그대로 주면 넓은 화면에서 한 변이 700px 넘는 정사각이 서서, 예약하러 온 손님이 그대로 주면 넓은 화면에서 한 변이 700px 넘는 정사각이 서서, 예약하러 온 손님이
이 한 칸을 스크롤로 넘겨야 다음 섹션에 닿는다. 미리보기는 손바닥만 하면 된다. 이 한 칸을 스크롤로 넘겨야 다음 섹션에 닿는다. 미리보기는 손바닥만 하면 된다.
★ 320px 도 크다고 했다(2026-09-15, 같은 날 두 번째) — 240px 로 내렸다. */} ★ 폭은 두 번 왔다 갔다 했다(2026-09-15): 320 → 240(너무 작다) → **360**.
<div className="mx-auto grid max-w-2xl gap-4 sm:grid-cols-[minmax(0,240px)_minmax(0,1fr)] sm:items-start"> 기준은 "한 화면에서 엽서와 입력칸이 나란히 보이는 가장 큰 크기" 다. */}
<div className="mx-auto grid max-w-3xl gap-5 sm:grid-cols-[minmax(0,360px)_minmax(0,1fr)] sm:items-start">
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={POSTCARD_SIZE} width={POSTCARD_SIZE}
height={POSTCARD_SIZE} height={POSTCARD_SIZE}
aria-label="내가 만든 엽서 미리보기" aria-label="내가 만든 엽서 미리보기"
className="border-line mx-auto w-full max-w-[240px] border" className="border-line mx-auto w-full max-w-[360px] border"
/> />
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<div className="slider-viewport" role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`} tabIndex={0}> {/* ★ 이 사이트가 쓰는 캐러셀을 그대로 쓴다 (2026-09-15 대표: "왜 이미지 리스트에
<div className="slider-track" style={{gap: '0.5rem'}}> slide js 안 걸려 있음?"). 처음엔 `.slider-viewport`·`.slider-track` 마크업만
손으로 찍었는데, 그 둘은 **스크립트가 붙기 전의 기본 상태**다(Carousel.tsx 주석)
— 손가락·트랙패드로만 밀리고 휠 마우스로는 옆에 더 있는 줄도 모른다. */}
<div role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`}>
<Carousel label="엽서 사진" gap={0.5} dots={false} autoplay={false}>
{photos.map((photo, i) => ( {photos.map((photo, i) => (
<button <button
key={photo.url} key={photo.url}
@ -140,7 +145,7 @@ export function PostcardMakerSection() {
<img src={photo.url} alt="" loading="lazy" className="size-full object-cover" /> <img src={photo.url} alt="" loading="lazy" className="size-full object-cover" />
</button> </button>
))} ))}
</div> </Carousel>
</div> </div>
<textarea <textarea

View File

@ -24,8 +24,38 @@ import {
tally, tally,
} from './common'; } from './common';
/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다. */ /**
const FALLBACK_LABEL = ITEM_ACCENT; * 곡이 라벨 색을 안 줬을 때 쓸 색.
*
* ★ 왜 팔레트인가 (2026-09-15 대표 지적: "왜 색깔 다 빨간색이야")
* 예전 폴백은 템플릿 강조색 하나였다(`ITEM_ACCENT`). 그런데 **실측 67곡 중 63곡에
* `labelColor` 가 없다** — 프롬프트가 그 칸을 시키는데도 모델이 공통 규칙 2
* ("확인되지 않은 값은 필드를 통째로 뺀다")를 색에까지 적용해 버린다. 결과는
* 판 스물다섯 장이 전부 같은 주朱색인 진열대였다. 색은 사실이 아니라 디자인 값이라
* **비어 있어도 화면은 달라야 한다.**
* ★ 옛 레코드 라벨에서 고른 진한 색들이다. 어두운 판 위에 얹히므로 밝은 색은 안 쓴다.
* ★ 제목으로 고른다 — **무작위가 아니다.** 서버에서 구운 색과 브라우저가 고른 색이
* 달라지면 하이드레이션이 어긋나고, 새로고침마다 판 색이 바뀌면 그것도 우스워진다.
*/
const LABEL_PALETTE = [
'#8b1e3f', // 버건디
'#1f4e79', // 감청
'#2f5d30', // 짙은 초록
'#a8541a', // 주황갈색
'#5a3a22', // 고동
'#0f5d7a', // 청록
'#6b2d5c', // 자주
'#7a6216', // 겨자
];
/** 제목 → 팔레트 index. 짧고 안정적이면 된다(암호학적 용도가 아니다). */
function labelColorOf(song: SongItem): string {
if (song.labelColor) return song.labelColor;
const key = song.title ?? '';
let hash = 0;
for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
return LABEL_PALETTE[hash % LABEL_PALETTE.length];
}
export function SongsSection() { export function SongsSection() {
const payload = useSite(); const payload = useSite();
@ -72,7 +102,7 @@ export function SongsSection() {
<div <div
className="w4-disc w4-spin absolute inset-2 rounded-full" className="w4-disc w4-spin absolute inset-2 rounded-full"
style={{ style={{
['--lbl' as string]: current.labelColor || FALLBACK_LABEL, ['--lbl' as string]: labelColorOf(current),
['--w4-vinyl' as string]: ITEM_INVERSE, ['--w4-vinyl' as string]: ITEM_INVERSE,
}} }}
> >
@ -159,7 +189,7 @@ export function SongsSection() {
<span <span
className="w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105" className="w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105"
style={{ style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL, ['--lbl' as string]: labelColorOf(song),
['--w4-vinyl' as string]: ITEM_INVERSE, ['--w4-vinyl' as string]: ITEM_INVERSE,
boxShadow: boxShadow:
index === playing ? `0 0 0 2px ${ITEM_ACCENT}` : undefined, index === playing ? `0 0 0 2px ${ITEM_ACCENT}` : undefined,