도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
더하면 어긋날 자리가 하나 더 생기기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
3.7 KiB
TypeScript
85 lines
3.7 KiB
TypeScript
/**
|
|
* 히어로 · 표지 — 사진 한 장 위, 글은 좌하단.
|
|
*
|
|
* ★ 왜 가운데가 아닌가
|
|
* 사진이 주인공인 화면에서 글자를 한가운데 얹으면 피사체를 정확히 가린다. 숙소 사진은
|
|
* 가운데에 방이나 사람이 오는데, 상호가 그 위에 앉으면 둘 다 못 읽는다. 아래로 내리고
|
|
* 그쪽만 어둡게 덮으면 사진은 사진대로 남고 글자는 글자대로 읽힌다.
|
|
*
|
|
* ★ 버튼을 세우지 않는다
|
|
* 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다. 예약 버튼은 아래 예약 섹션이 맡고,
|
|
* 여기서는 다음 섹션으로 눈을 내려보내기만 한다.
|
|
*
|
|
* ★ 색을 직접 쓰지 않는다
|
|
* `bg-stone-900` 같은 고정색을 두면 '옛 항구'(갱지)를 골라도 첫 화면만 검게 남는다.
|
|
* 어두운 면은 `--tpl-inverse`, 그 위 글자는 `--tpl-bg` 다 — 팔레트가 바뀌면 같이 바뀐다.
|
|
*/
|
|
import {ChevronDown, MapPin} from 'lucide-react';
|
|
import {SectionFrame} from '../../primitives';
|
|
import type {SectionRenderProps} from '../../types';
|
|
import {primaryPhoto} from '../common';
|
|
|
|
/** 사진 위 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */
|
|
const SCRIM =
|
|
'linear-gradient(to top,' +
|
|
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%,' +
|
|
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%,' +
|
|
' transparent 66%)';
|
|
|
|
export function HeroCover(props: SectionRenderProps) {
|
|
const {section, isSelected, onSelect, storeName, location, photos} = props;
|
|
|
|
// 사진이 없으면 <img> 없이 어두운 판만 남긴다 — 시연용 사진으로 자리를 메우지 않는다.
|
|
const cover = primaryPhoto(photos);
|
|
|
|
return (
|
|
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
|
|
<div
|
|
className="relative flex w-full select-none items-end overflow-hidden"
|
|
style={{
|
|
backgroundColor: 'var(--tpl-inverse, #1c1917)',
|
|
color: 'var(--tpl-bg, #ffffff)',
|
|
minHeight: 'clamp(24rem, 62vh, 36rem)',
|
|
}}
|
|
>
|
|
{cover?.url && (
|
|
<div className="absolute inset-0 z-0">
|
|
<img src={cover.url} alt={storeName} className="size-full object-cover object-center" />
|
|
<div className="absolute inset-0" style={{background: SCRIM}} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="shell relative z-10 pb-10 pt-24 sm:pb-12">
|
|
{location && (
|
|
<p className="mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80">
|
|
<MapPin className="size-3.5" aria-hidden />
|
|
{location}
|
|
</p>
|
|
)}
|
|
|
|
{/* ★ tpl-title — 굵기를 템플릿이 정한다. 간판체(Gugi)는 굵기가 한 벌뿐이라
|
|
700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. */}
|
|
<h1
|
|
className="tpl-title leading-[1.15]"
|
|
style={{fontSize: 'var(--fs-display)'}}
|
|
>
|
|
{storeName}
|
|
</h1>
|
|
|
|
{/*
|
|
* ★ 여기에 `section.description` 을 쓰지 않는다.
|
|
* 그 칸은 에디터의 섹션 설명("상단 메인 비주얼과 대표 문구")이라, 캔버스에 그리면
|
|
* 사장님 화면에 우리 UI 안내문이 자기 소개문처럼 박힌다. 실제로 그렇게 나갔다.
|
|
* 대표 문구는 수집·생성된 값이 생기기 전까지 **아무것도 그리지 않는다.**
|
|
*/}
|
|
|
|
<p className="mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] opacity-80">
|
|
객실 보기
|
|
<ChevronDown className="size-4" aria-hidden />
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</SectionFrame>
|
|
);
|
|
}
|