/** * 붙여넣기 아이템이 공유하는 조각 — 색·서체 토큰, 출처 각주, 캐러셀 화살표. * * ★ 색과 서체를 여기에 박지 않는다. 전부 템플릿 토큰(`--tpl-*`)을 읽는다 — * 한때 이 파일은 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 들고 있었고, * 그 바람에 사장님이 템플릿을 매거진으로 바꿔도 아이템만 레트로로 남았다. * 아이템은 '레트로 전용 부품'이 아니라 **어느 템플릿에나 들어가는 섹션**이다. * ★ 질감(도넛판 홈·톱니·펀치 구멍)은 남긴다 — 그건 색이 아니라 물건의 생김새다. * 질감의 색도 토큰을 따라가게 `items.css` 에서 var() 로 받는다. */ import {useCallback, useRef} from 'react'; import {ChevronLeft, ChevronRight, MousePointerClick} from 'lucide-react'; import {cn} from '@/lib/utils'; import type {DataSource, DataVerified} from '@o2o/shared'; /** 제목 서체. 템플릿이 정한다(심플=고딕 · 매거진=명조 · 레트로=간판체). */ export const ITEM_HEADING = 'var(--tpl-font-heading, inherit)'; /** 본문 서체. */ export const ITEM_BODY = 'var(--tpl-font-body, inherit)'; /** 섹션 바탕. */ export const ITEM_SURFACE = 'var(--tpl-surface, #faf7f2)'; /** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */ export const ITEM_CARD = 'var(--tpl-card, #ffffff)'; /** 글자색. */ export const ITEM_INK = 'var(--tpl-text, #1c1917)'; /** 선·테두리. */ export const ITEM_BORDER = 'var(--tpl-border, #d6d3d1)'; /** * 강조 — 연표의 전환점, 일력의 날짜, 소인 도장. * * ★ 템플릿 강조색을 **그대로 쓰지 않고 둘레 글자색을 섞는다.** 팔레트에 따라 accent 가 * 바탕과 거의 같은 밝기일 수 있는데(연한 베이지·파스텔), 그러면 큰 날짜 숫자와 배지가 * 화면에서 사라진다. 섞으면 색조는 남고 대비만 확보된다 — * currentColor 라서 어두운 면에서는 밝은 쪽으로, 밝은 면에서는 어두운 쪽으로 붙는다. */ export const ITEM_ACCENT = 'color-mix(in oklab, var(--tpl-accent, #bf2f1b) 70%, currentColor)'; /** 어두운 면(필름·플립보드·턴테이블). */ export const ITEM_INVERSE = 'var(--tpl-inverse, #1c1917)'; /** * 어두운 면 위의 글자색. * * ★ 흰색을 박지 않는다. 템플릿 배경색을 글자색으로 쓰면 어떤 팔레트에서도 대비가 선다 — * 밝은 템플릿이면 밝은 글자, 어두운 템플릿이면 그 템플릿이 정한 밝은 면 색이다. */ export const ITEM_INVERSE_INK = 'var(--tpl-bg, #ffffff)'; /** * 출처 한 줄 + 확신 배지. * * ★ 확인되지 않은 값을 숨기지 않고 **드러낸다.** 숨기면 사장님이 그게 미검증인 줄 모르고 발행한다. * ★ 배지 색만은 토큰이 아니다 — '확인/확인필요'는 디자인이 아니라 신호다. 템플릿 색을 따라가면 * 팔레트에 따라 경고가 안 보이는 색이 될 수 있다. */ export function SourceLine({ source, verified, tone = 'light', }: { source?: DataSource; verified?: DataVerified; tone?: 'light' | 'dark'; }) { if (!source && !verified) return null; return (

{verified && ( {verified} )} {source?.name && ( 출처 ·{' '} {source.url ? ( event.stopPropagation()} className="underline underline-offset-2" > {source.name} ) : ( source.name )} )}

); } /** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */ export function useCarousel() { const ref = useRef(null); const scrollBy = useCallback((dir: -1 | 1) => { const box = ref.current; if (!box) return; box.scrollBy({left: box.clientWidth * 0.8 * dir, behavior: 'smooth'}); }, []); return {ref, scrollBy}; } export function CarouselNav({ onPrev, onNext, tone = 'light', label, }: { onPrev: () => void; onNext: () => void; tone?: 'light' | 'dark'; label: string; }) { const dark = tone === 'dark'; const style = { borderColor: dark ? 'color-mix(in oklab, currentColor 35%, transparent)' : ITEM_BORDER, backgroundColor: dark ? 'transparent' : ITEM_CARD, color: 'inherit', }; const stop = (fn: () => void) => (event: React.MouseEvent) => { event.stopPropagation(); fn(); }; return (
); } /** * 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다. * * ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다. * ★ 이건 발행되지 않는 **에디터 안내**다. 그래서 템플릿 색이 아니라 관리자 색을 그대로 쓴다. */ export function PasteHint({label}: {label: string}) { return (

{label} 내용이 아직 없습니다

오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다.

); } /** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다(에디터 안내라 관리자 색). */ export function ParseError({message}: {message: string}) { return (

붙여넣은 JSON 을 읽지 못했습니다

{message}

); }