[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:
parent
6b9e01d876
commit
f0c4d5f413
@ -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)가 "불가" 로
|
||||
결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다.
|
||||
- **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.**
|
||||
|
||||
@ -15,6 +15,7 @@ import router.v1.place.place
|
||||
import router.v1.fact.fact
|
||||
import router.v1.faq.faq
|
||||
import router.v1.media.media
|
||||
import router.v1.media.relay
|
||||
import router.v1.job.job
|
||||
import router.v1.site.site
|
||||
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.faq.faq.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.site.site.router)
|
||||
app.include_router(router.v1.site.site.my_router)
|
||||
|
||||
87
solution/backend/router/v1/media/relay.py
Normal file
87
solution/backend/router/v1/media/relay.py
Normal 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"},
|
||||
)
|
||||
@ -7,7 +7,7 @@
|
||||
"label": "가요 다방",
|
||||
"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://...\"} } ] }",
|
||||
"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": {
|
||||
"kind": "daily",
|
||||
|
||||
@ -92,6 +92,9 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
|
||||
[이 아이템만의 규칙]
|
||||
· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.
|
||||
· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.
|
||||
**이건 사실이 아니라 디자인 값이다 — 공통 규칙 2 를 적용하지 말고 곡마다 반드시 채운다.**
|
||||
앞뒤 곡과 같은 색을 쓰지 않는다. 비워 두면 판이 전부 한 색으로 깔린다(실측 2026-09-15:
|
||||
67곡 중 63곡이 비어 있었다).
|
||||
· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다.
|
||||
`,
|
||||
},
|
||||
|
||||
@ -112,22 +112,39 @@ function stampLines(ctx: CanvasRenderingContext2D, name: string, maxWidth: numbe
|
||||
* **CORS 없이 다시 받아 미리보기만** 세운다(저장·공유는 부르는 쪽이 막는다).
|
||||
* ★ **2026-09-15 에 근본을 고쳤다** — 굽는 쪽이 사진을 내려받아 `s/<slug>/img/` 에 두고
|
||||
* 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}> {
|
||||
const tryLoad = (crossOrigin: string | null) => new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
if (crossOrigin) img.crossOrigin = crossOrigin;
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
const tryLoad = (src: string, crossOrigin: string | null) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
if (crossOrigin) img.crossOrigin = crossOrigin;
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
|
||||
try {
|
||||
return {img: await tryLoad('anonymous'), exportable: true};
|
||||
return {img: await tryLoad(url, 'anonymous'), exportable: true};
|
||||
} 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};
|
||||
}
|
||||
|
||||
/** 이 사이트가 쓰는 세리프를 그대로 쓴다 — 화면의 카드와 그림의 글씨가 갈리지 않게. */
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
*/
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
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';
|
||||
|
||||
const FILE_NAME = 'postcard.png';
|
||||
@ -114,18 +114,23 @@ export function PostcardMakerSection() {
|
||||
{/* ★ 폭을 묶어 둔다 (2026-09-15 대표: "엽서 ui 너무 큼"). 엽서는 1:1 이라 열 폭을
|
||||
그대로 주면 넓은 화면에서 한 변이 700px 넘는 정사각이 서서, 예약하러 온 손님이
|
||||
이 한 칸을 스크롤로 넘겨야 다음 섹션에 닿는다. 미리보기는 손바닥만 하면 된다.
|
||||
★ 320px 도 크다고 했다(2026-09-15, 같은 날 두 번째) — 240px 로 내렸다. */}
|
||||
<div className="mx-auto grid max-w-2xl gap-4 sm:grid-cols-[minmax(0,240px)_minmax(0,1fr)] sm:items-start">
|
||||
★ 폭은 두 번 왔다 갔다 했다(2026-09-15): 320 → 240(너무 작다) → **360**.
|
||||
기준은 "한 화면에서 엽서와 입력칸이 나란히 보이는 가장 큰 크기" 다. */}
|
||||
<div className="mx-auto grid max-w-3xl gap-5 sm:grid-cols-[minmax(0,360px)_minmax(0,1fr)] sm:items-start">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={POSTCARD_SIZE}
|
||||
height={POSTCARD_SIZE}
|
||||
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="slider-viewport" role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`} tabIndex={0}>
|
||||
<div className="slider-track" style={{gap: '0.5rem'}}>
|
||||
{/* ★ 이 사이트가 쓰는 캐러셀을 그대로 쓴다 (2026-09-15 대표: "왜 이미지 리스트에
|
||||
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) => (
|
||||
<button
|
||||
key={photo.url}
|
||||
@ -140,7 +145,7 @@ export function PostcardMakerSection() {
|
||||
<img src={photo.url} alt="" loading="lazy" className="size-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
|
||||
@ -24,8 +24,38 @@ import {
|
||||
tally,
|
||||
} 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() {
|
||||
const payload = useSite();
|
||||
@ -72,7 +102,7 @@ export function SongsSection() {
|
||||
<div
|
||||
className="w4-disc w4-spin absolute inset-2 rounded-full"
|
||||
style={{
|
||||
['--lbl' as string]: current.labelColor || FALLBACK_LABEL,
|
||||
['--lbl' as string]: labelColorOf(current),
|
||||
['--w4-vinyl' as string]: ITEM_INVERSE,
|
||||
}}
|
||||
>
|
||||
@ -159,7 +189,7 @@ export function SongsSection() {
|
||||
<span
|
||||
className="w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105"
|
||||
style={{
|
||||
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
|
||||
['--lbl' as string]: labelColorOf(song),
|
||||
['--w4-vinyl' as string]: ITEM_INVERSE,
|
||||
boxShadow:
|
||||
index === playing ? `0 0 0 2px ${ITEM_ACCENT}` : undefined,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user