[feat] solution/backend,frontend: 내 사이트 목록을 카드로 — 썸네일·주소·시각 · 발행마다 그림 갱신

목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 road_address·created_at·
published_at 을 주는데 화면이 안 썼다. 실측(계정 test): 35줄 중 34줄이 발행 전이고
같은 상호 '버터브루' 가 4줄이라 어느 게 어느 건지 가릴 단서가 화면에 없었다.

리서치 — Wix My Sites 는 이름·URL·Premium·협업자만 두고 검색·그리드/리스트 전환·폴더가 있다.
Sites API 문서가 권하는 조합은 displayName·thumbnail·viewUrl·editUrl 이다.
아임웹 내사이트는 **실제 화면을 열어 봤다**(imweb.me 가이드): 줄 왼쪽에 큰 가로형 썸네일,
상호 아래 도메인, 그리고 도메인/SSL·PG 신청처럼 **안 끝난 설정**을 줄 안에 배지로 늘어놓는다.
공통 원칙은 목록이 ① 구분 ② 상태 ③ 여는 길 셋만 한다는 것 — 통계는 사이트 안 대시보드다.

- protocol·site_service: `MySiteData.thumbnail_url` 추가. 목록이 사이트 행을 이미 조인해
  읽고 있어서 쿼리는 그대로다
- site_thumbnail: 공개 주소에 `?v=<발행 버전>`. 블롭 이름은 고정이고 내용만 덮어쓰므로
  주소가 안 변하면 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보인다
  (CACHE_CONTROL 60초로는 그 60초를 못 막는다). 이름에 버전을 넣지 않은 이유는
  사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없어서다
- site_thumbnail: 썸네일 전용 저장소 스위치(`THUMBNAIL_BLOB_*`). 예전엔 키 하나가
  사이트 전체 업로드(azure_static)까지 같이 켰다 — 둘은 필요한 저장소가 다르다
  (사이트는 정적 호스팅 `$web`, 썸네일은 이미지 버킷이면 된다)
- SitesPage: 줄 → **카드 그리드**. 썸네일은 16:10(브라우저 창 비율 — 사이트 미리보기를
  1:1 로 자르면 무슨 사이트인지 못 알아본다). 검색(상호·주소, 공백 무시) + 상태 칸
  `전체/발행됨/발행 전` 에 건수. 판정은 `bucketOf` 하나가 소유한다(배지·필터·정렬이 갈라지면
  건수가 어긋나 목록을 못 믿게 된다). 검색 0건 화면을 처음 온 사람의 빈 화면과 분리했다 —
  35개 있는데 "아직 없습니다" 라고 말하던 자리다
- 카드 골격은 **상태와 무관하게 같다**. 발행 전 카드에만 줄이 하나 더 붙어 높이와 버튼
  위치가 어긋났다(사장님 지적). 버튼 문구도 '편집' 하나로 — 하는 일이 같은데 글자만 달랐다

아직 그림이 없는 사이트가 대부분이다. 썸네일은 발행에 성공해야 생긴다.

검증: 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 전 줄에
`thumbnail_url` 키가 아예 없는지, 재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지 4건 추가.
프론트 tsc+eslint 통과. 실제 발행으로 블롭 업로드(232KB) → 공개 주소 200 → 목록 반영 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
This commit is contained in:
Mina Choi 2026-09-08 13:01:47 +09:00
parent 94551afdaf
commit 9f16c3224b
11 changed files with 488 additions and 87 deletions

View File

@ -5,6 +5,49 @@
--- ---
## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다
**무슨 일**
목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 `road_address`·`created_at`·
`published_at` 을 주고 있는데 화면이 안 썼다. 한 계정에 '버터브루' 가 4줄 있으면 어느 게
어느 건지 가릴 단서가 화면에 하나도 없다.
**리서치** (Wix · 아임웹)
- Wix `My Sites` 줄에 보이는 건 이름·URL·Premium·협업자뿐이고 **썸네일도 수정일도 없다.**
대신 Sites API 문서가 "이렇게 그려라" 로 지목한 조합은 `displayName · thumbnail · viewUrl ·
editUrl` 이고, 정렬은 최근 수정순이다 — 화면보다 API 권고 쪽이 우리 상황에 맞다.
- 아임웹 내사이트는 기본 정보 + 액션(관리자 접속·복제·템플릿 변경·소유권 이전),
리셀러 목록은 **만료일**을 목록에서 바로 본다. 방문자·주문 숫자는 목록이 아니라
사이트 안 대시보드에 있다.
- 공통: 목록은 **구분 · 상태 · 여는 길** 셋만 한다. 그리고 **둘 다 생성일을 안 쓴다** —
구분은 그림·주소·이름이 하고, 시각은 "마지막으로 뭔가 한 시각" 이 쓰인다.
**바꾼 것**
- `MySiteData.thumbnail_url` 추가(`site_service._my_site_row`). 목록이 사이트 행을 이미
조인해 읽고 있어서 쿼리는 그대로다
- 줄 앞에 썸네일. 없으면 업종 아이콘으로 떨어지고, 로드 실패해도 아이콘으로 되돌린다 —
블롭이 지워진 옛 주소에서 깨진 그림이 뜨는 것보다 낫다
- 줄 아래 한 칸: `도로명 주소 · 시각`. 시각은 **발행됐으면 발행일, 아니면 만든 날** 하나만
쓴다(위 리서치의 결론). 올해면 연도를 뗀다 — 줄이 좁아 주소가 먼저 잘린다
**썸네일이 발행마다 바뀌게** (`site_thumbnail.public_url`)
블롭 이름은 `thumbs/<slug>.<ext>` 로 고정이고 내용만 `overwrite=True` 로 덮어쓴다. 그래서
주소가 안 변했고, 사장님이 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보였다
(`CACHE_CONTROL` 60초만으로는 그 60초를 못 막는다). 주소에 `?v=<발행 버전>` 을 붙인다.
→ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없다.
→ `scripts/backfill_thumbnails.py` 처럼 그 시점 버전이 없는 경로는 `version=None` 으로
그냥 붙이지 않는다.
**아직 그림이 한 장도 없다** — 로컬·현재 DB 의 사이트 39개 전부 `thumbnail_url` 이 NULL 이다.
버그가 아니라 `AZURE_STORAGE_CONNECTION_STRING` 이 비어 `site_thumbnail.is_configured()` 가
False 라서다(썸네일은 Blob 에만 올라간다). 키를 채우면 다음 발행부터 채워진다.
**검증** — 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 안 한 줄에
`thumbnail_url` 키가 아예 없는지, **재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지** 4건 추가.
프론트 `tsc + eslint` 통과.
---
## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다 ## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다
**무슨 일** **무슨 일**

View File

@ -85,6 +85,8 @@ class MySiteData(WebPacketProtocol):
domain: Optional[str] = None domain: Optional[str] = None
template_id: Optional[str] = None template_id: Optional[str] = None
published_at: Optional[datetime] = None published_at: Optional[datetime] = None
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
thumbnail_url: Optional[str] = None
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다. # 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
needs_rebuild: bool = False needs_rebuild: bool = False
@ -235,7 +237,7 @@ class ShowcaseItem(WebPacketProtocol):
"""랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** """랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.""" 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다."""
name: str name: str

View File

@ -97,18 +97,18 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult
async def run_build(job: dict) -> dict: async def run_build(job: dict) -> dict:
"""BUILD 잡 핸들러. payload: {place_id, company_id, publish?, requested_by?} """BUILD 잡 핸들러. payload: {place_id, owner_user_id, publish?, requested_by?}
publish=True 면 게이트를 통과했을 때 바로 발행까지 한다.""" publish=True 면 게이트를 통과했을 때 바로 발행까지 한다."""
payload = job["payload"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
company_id = payload["company_id"] owner_user_id = payload["owner_user_id"]
want_publish = bool(payload.get("publish")) want_publish = bool(payload.get("publish"))
err, place = await DB_SESSION_MNG.execute_lambda( err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
) )
if err != ErrorType.SUCCESS or place is None: if err != ErrorType.SUCCESS or place is None:
raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}")
@ -246,7 +246,7 @@ async def run_build(job: dict) -> dict:
result["azure"] = azure_result result["azure"] = azure_result
# ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다. # ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다.
# 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다). # 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다).
thumbnail_url = await site_thumbnail.store(slug, snapshot) thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no)
if thumbnail_url: if thumbnail_url:
result["thumbnail_url"] = thumbnail_url result["thumbnail_url"] = thumbnail_url
# ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다. # ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다.
@ -293,7 +293,7 @@ async def run_build(job: dict) -> dict:
await DB_SESSION_MNG.execute_lambda_claim( await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: _place_crud.update_place( lambda s: _place_crud.update_place(
s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
), ),
) )
await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None, await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None,

View File

@ -104,7 +104,7 @@ class SiteService:
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None
@ -420,16 +420,16 @@ class SiteService:
return cleaned return cleaned
async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites: async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites:
"""로그인한 계정(회사)이 가진 사이트 전부. """로그인한 사장님이 가진 사이트 전부.
사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라 사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라
'발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다.""" '발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다."""
res = Res_MySites(page=pg.page, size=pg.size) res = Res_MySites(page=pg.page, size=pg.size)
cid = uuid.UUID(user_info.company_id) uid = uuid.UUID(user_info.user_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda( err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size), lambda s: self.crud.list_owner_sites(s, uid, pg.skip, pg.size),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -454,6 +454,7 @@ class SiteService:
domain=getattr(site, "domain", None), domain=getattr(site, "domain", None),
template_id=getattr(site, "template_id", None), template_id=getattr(site, "template_id", None),
published_at=getattr(site, "published_at", None), published_at=getattr(site, "published_at", None),
thumbnail_url=getattr(site, "thumbnail_url", None),
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)), needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
) )
@ -526,7 +527,7 @@ class SiteService:
job_id, created = await enqueue_job( job_id, created = await enqueue_job(
self.queue, JobType.BUILD, self.queue, JobType.BUILD,
{ {
"place_id": place_id, "company_id": user_info.company_id, "place_id": place_id, "owner_user_id": user_info.user_id,
"publish": req.publish, "requested_by": user_info.user_id, "publish": req.publish, "requested_by": user_info.user_id,
}, },
dedupe_key=f"build:{place_id}", dedupe_key=f"build:{place_id}",

View File

@ -18,7 +18,7 @@ import asyncio
import os import os
import httpx import httpx
from azure.storage.blob import BlobServiceClient, ContentSettings from azure.storage.blob import BlobClient, BlobServiceClient, ContentSettings
from common.logger import LOG from common.logger import LOG
from services import azure_static, site_payload from services import azure_static, site_payload
@ -46,19 +46,71 @@ MAX_BYTES = 5 * 1024 * 1024
CACHE_CONTROL = "public, max-age=60, must-revalidate" CACHE_CONTROL = "public, max-age=60, must-revalidate"
# ── 썸네일 전용 저장소 ────────────────────────────────────────────────────────
# ★ 왜 스위치를 따로 두나
# 원래는 `AZURE_STORAGE_CONNECTION_STRING` 하나가 사이트 업로드(azure_static)와 썸네일을
# **같이** 켰다. 그런데 그 둘은 필요한 저장소가 다르다 — 사이트는 정적 호스팅(`$web`)이고
# 썸네일은 그냥 이미지 버킷이면 된다. 하나로 묶어 두면 "썸네일 좀 보자" 고 키를 꽂는 순간
# **발행할 때마다 사이트 전체가 그 버킷에 업로드된다.** 지금 우리가 빌려 쓰는 곳은
# negodata·infinith 와 공용인 미디어 컨테이너라 그렇게 되면 안 된다.
#
# ★ 값 출처: o2o-negosium/negodata/backend/config/config.local.toml `[StorageConfig]`.
# 같은 계정/컨테이너를 root 디렉터리로만 가른다(negodata/ · infinith/ · web4ai/) —
# 그쪽 관례를 그대로 따른 것이지 우리 계정이 아니다.
#
# ⚠️ **임시다.** 이 컨테이너는 정적 사이트 호스팅이 아니라 발행본을 못 올린다. 그리고 SAS 가
# 컨테이너 전체에 racwdl(삭제 포함)이라, 남의 프로젝트 파일에 닿을 수 있는 자리다 —
# web4ai 전용 스토리지 계정이 생기면 이 블록을 걷고 azure_static 쪽으로 되돌린다.
_BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너>
_SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다)
_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. 예: web4ai
def _blob_base() -> str:
return os.environ.get(_BASE_ENV, "").strip().rstrip("/")
def _blob_sas() -> str:
return os.environ.get(_SAS_ENV, "").strip().lstrip("?")
def _blob_root() -> str:
return os.environ.get(_ROOT_ENV, "").strip().strip("/")
def uses_blob_store() -> bool:
"""썸네일 전용 저장소를 쓰는가. 아니면 예전대로 azure_static 설정을 따른다."""
return bool(_blob_base() and _blob_sas())
def is_configured() -> bool: def is_configured() -> bool:
return azure_static.is_configured() return uses_blob_store() or azure_static.is_configured()
def blob_name(slug: str, ext: str) -> str: def blob_name(slug: str, ext: str) -> str:
if uses_blob_store():
# base_url 에 컨테이너까지 들어 있다 — 여기서는 컨테이너 안쪽 경로만 만든다.
return "/".join(part for part in (_blob_root(), THUMB_DIR, f"{slug}.{ext}") if part)
prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/") prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/")
return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part) return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part)
def public_url(slug: str, ext: str) -> str: def public_url(slug: str, ext: str, version: int | None = None) -> str:
"""공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다 """공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다
(CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').""" (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').
return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}"
★ `?v=<버전>` 은 캐시 무효화다. 블롭 이름은 발행마다 그대로고 내용만 덮어쓰므로
(overwrite=True), 주소가 안 변하면 브라우저·CDN 이 **옛 그림을 계속 보여준다.**
아래 CACHE_CONTROL(60초)만으로는 부족하다 — 그 60초 동안 사장님은 방금 바꾼 사진이
아니라 지난 발행의 사진을 본다. 버전을 붙이면 발행 즉시 새 주소가 된다.
★ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이고, 지우는 코드가 없다.
★ version 이 None 이면 붙이지 않는다 — 옛 발행분을 사후에 채우는 경로
(scripts/backfill_thumbnails.py)에는 그 시점의 버전이 없다."""
# ★ 저장소가 발행 오리진 밖이면 주소도 그쪽이다. 여기서 publish_origin 을 쓰면
# 그림은 블롭에 올라가 있는데 카드는 우리 사이트 주소를 가리켜 전부 404 다.
base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \
else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}"
return f"{base}?v={version}" if version is not None else base
async def _fetch(url: str) -> tuple[bytes, str, str] | None: async def _fetch(url: str) -> tuple[bytes, str, str] | None:
@ -109,6 +161,18 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | None:
def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
name = blob_name(slug, ext)
settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL)
if uses_blob_store():
# ★ SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다.
# 블롭 주소에 토큰을 붙여 그 한 파일에만 붙는다(컨테이너 클라이언트를 만들지 않는다:
# 남의 디렉터리를 훑을 수 있는 핸들을 굳이 들고 있지 않는다).
BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob(
data, overwrite=True, content_settings=settings
)
return name
connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip() connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip()
container_name = ( container_name = (
os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip() os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip()
@ -116,18 +180,12 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
) )
service = BlobServiceClient.from_connection_string(connection_string) service = BlobServiceClient.from_connection_string(connection_string)
container = service.get_container_client(container_name) container = service.get_container_client(container_name)
name = blob_name(slug, ext) # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
container.upload_blob( container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings)
name=name,
data=data,
overwrite=True,
# cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL),
)
return name return name
async def store(slug: str, snapshot: dict) -> str | None: async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None:
"""대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다). """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다).
SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로, SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로,
@ -153,4 +211,4 @@ async def store(slug: str, snapshot: dict) -> str | None:
return None return None
LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})") LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})")
return public_url(slug, ext) return public_url(slug, ext, version)

View File

@ -53,6 +53,42 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client):
assert row["status"] == SiteStatus.DRAFT.value assert row["status"] == SiteStatus.DRAFT.value
async def test_row_carries_what_the_card_draws(auth_headers, client, db_engine):
"""검증: 목록 줄이 카드가 그릴 값을 다 들고 온다 — 주소·생성일·썸네일.
★ 왜 이걸 본다: 같은 상호로 만든 사업장이 여러 줄일 때(실측: 한 계정에 '버터브루' 4줄)
이름과 상태 배지만으로는 어느 게 어느 건지 가릴 수 없다. 가르는 값은 주소와 시각이고,
**한 번이라도 발행한 줄은 그림**이다."""
h = await auth_headers("my2b")
pid = await _place(client, h, "카드펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE places SET road_address = :addr WHERE place_id = :pid"),
{"addr": "강원특별자치도 양양군 현북면 하조대3길 12", "pid": uuid.UUID(pid)},
)
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status, thumbnail_url) "
"VALUES (:s, :p, :d, :st, :t)"),
{"s": uuid.uuid4(), "p": uuid.UUID(pid), "d": "card-stay",
"st": SiteStatus.PUBLISHED.value, "t": "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2"},
)
row = (await _list(client, h))["sites"][0]
assert row["road_address"] == "강원특별자치도 양양군 현북면 하조대3길 12"
assert row["created_at"]
assert row["thumbnail_url"] == "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2"
async def test_row_without_a_site_has_no_thumbnail(auth_headers, client):
"""검증: 아직 발행 안 한 줄은 그림이 없다(키 자체가 없다).
기대결과: 화면이 '그림 없음' 자리를 그릴 근거가 된다 — 빈 문자열로 오면 깨진 이미지가 뜬다."""
h = await auth_headers("my2c")
await _place(client, h, "그림없는펜션")
row = (await _list(client, h))["sites"][0]
assert "thumbnail_url" not in row
async def test_other_owners_sites_are_not_listed(auth_headers, client): async def test_other_owners_sites_are_not_listed(auth_headers, client):
"""검증: 사장님 스코프. 남의 사업장은 보이지 않는다. """검증: 사장님 스코프. 남의 사업장은 보이지 않는다.
기대결과: 각자 자기 것만 1건.""" 기대결과: 각자 자기 것만 1건."""

View File

@ -110,9 +110,11 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa
_transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"jpegbytes")) _transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"jpegbytes"))
snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None}) snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None})
url = await site_thumbnail.store("butter", snapshot) url = await site_thumbnail.store("butter", snapshot, 3)
assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg" # ★ `?v=` 는 캐시 무효화다. 블롭 이름은 그대로 덮어쓰므로 주소가 안 변하면
# 브라우저·CDN 이 지난 발행의 그림을 계속 보여준다(site_thumbnail.public_url).
assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg?v=3"
name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg" name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg"
assert set(blob.uploads) == {name} assert set(blob.uploads) == {name}
assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/") assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/")
@ -131,8 +133,8 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head
from services import build_service from services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts from tests.test_build_publish import _approved_media, _place, _run, _verified_facts
async def _store(slug, snapshot): async def _store(slug, snapshot, version=None):
return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg" return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg?v={version}"
monkeypatch.setattr(build_service.site_thumbnail, "store", _store) monkeypatch.setattr(build_service.site_thumbnail, "store", _store)
@ -146,6 +148,15 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head
site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"]
assert site["thumbnail_url"].startswith("https://w4ai.o2o.kr/thumbs/") assert site["thumbnail_url"].startswith("https://w4ai.o2o.kr/thumbs/")
assert site["thumbnail_url"].endswith("?v=1")
# ★ 재발행하면 주소가 바뀌어야 한다. 블롭 이름은 그대로 덮어쓰므로, 주소가 그대로면
# 사장님은 사진을 바꾸고 다시 발행해도 캐시에 남은 **지난 그림**을 계속 본다.
await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})
await _run()
again = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"]
assert again["thumbnail_url"].endswith("?v=2")
assert again["thumbnail_url"] != site["thumbnail_url"]
async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch): async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch):
@ -154,7 +165,7 @@ async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers
from services import build_service from services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts from tests.test_build_publish import _approved_media, _place, _run, _verified_facts
async def _store(slug, snapshot): async def _store(slug, snapshot, version=None):
return None return None
monkeypatch.setattr(build_service.site_thumbnail, "store", _store) monkeypatch.setattr(build_service.site_thumbnail, "store", _store)

View File

@ -10,7 +10,6 @@ export * from './auditCheckDataRecommendation';
export * from './authProvider'; export * from './authProvider';
export * from './buildStatus'; export * from './buildStatus';
export * from './checkSlugParams'; export * from './checkSlugParams';
export * from './companyData';
export * from './errorInfo'; export * from './errorInfo';
export * from './errorInfoCode'; export * from './errorInfoCode';
export * from './errorInfoDesc'; export * from './errorInfoDesc';
@ -83,6 +82,7 @@ export * from './mySiteData';
export * from './mySiteDataCreatedAt'; export * from './mySiteDataCreatedAt';
export * from './mySiteDataDomain'; export * from './mySiteDataDomain';
export * from './mySiteDataPublishedAt'; export * from './mySiteDataPublishedAt';
export * from './mySiteDataThumbnailUrl';
export * from './mySiteDataRoadAddress'; export * from './mySiteDataRoadAddress';
export * from './mySiteDataSiteId'; export * from './mySiteDataSiteId';
export * from './mySiteDataStatus'; export * from './mySiteDataStatus';
@ -139,7 +139,6 @@ export * from './reqGoogleLogin';
export * from './reqLogin'; export * from './reqLogin';
export * from './reqPublishLocalContent'; export * from './reqPublishLocalContent';
export * from './reqSignup'; export * from './reqSignup';
export * from './reqSignupCompanyName';
export * from './reqSignupName'; export * from './reqSignupName';
export * from './reqSiteSlug'; export * from './reqSiteSlug';
export * from './reqSiteStatus'; export * from './reqSiteStatus';
@ -222,7 +221,6 @@ export * from './resLocalContentListMsg';
export * from './resLogin'; export * from './resLogin';
export * from './resLoginMsg'; export * from './resLoginMsg';
export * from './resMe'; export * from './resMe';
export * from './resMeCompany';
export * from './resMeContactNumber'; export * from './resMeContactNumber';
export * from './resMeEmail'; export * from './resMeEmail';
export * from './resMeMsg'; export * from './resMeMsg';

View File

@ -13,6 +13,7 @@ import type { MySiteDataStatus } from './mySiteDataStatus';
import type { MySiteDataDomain } from './mySiteDataDomain'; import type { MySiteDataDomain } from './mySiteDataDomain';
import type { MySiteDataTemplateId } from './mySiteDataTemplateId'; import type { MySiteDataTemplateId } from './mySiteDataTemplateId';
import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt'; import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt';
import type { MySiteDataThumbnailUrl } from './mySiteDataThumbnailUrl';
/** /**
* 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). * 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
@ -32,5 +33,7 @@ export interface MySiteData {
domain?: MySiteDataDomain; domain?: MySiteDataDomain;
template_id?: MySiteDataTemplateId; template_id?: MySiteDataTemplateId;
published_at?: MySiteDataPublishedAt; published_at?: MySiteDataPublishedAt;
/** 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). */
thumbnail_url?: MySiteDataThumbnailUrl;
needs_rebuild?: boolean; needs_rebuild?: boolean;
} }

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataThumbnailUrl = string | null;

View File

@ -1,4 +1,4 @@
import {useState} from 'react'; import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router'; import {Link, useNavigate} from 'react-router';
import { import {
Building2, Building2,
@ -8,17 +8,22 @@ import {
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
Plus, Plus,
Search,
SearchX,
Stethoscope, Stethoscope,
UtensilsCrossed, UtensilsCrossed,
Wand2, Wand2,
X,
} from 'lucide-react'; } from 'lucide-react';
import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared'; import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared';
import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api'; import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api';
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notify, notifyApiError} from '@/lib/notify'; import {notify, notifyApiError} from '@/lib/notify';
import {PUBLISH_HOST} from '@/lib/site'; import {PUBLISH_HOST} from '@/lib/site';
import {cn} from '@/lib/utils';
// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다. // 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다.
// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 // ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
@ -32,24 +37,51 @@ const CATEGORY_ICON: Record<number, typeof Building2> = {
[PlaceCategory.CLINIC]: Stethoscope, [PlaceCategory.CLINIC]: Stethoscope,
}; };
/**
* 줄이 속하는 칸. **배지·필터·정렬이 전부 이 하나로 판정한다.**
*
* ★ 판정을 갈라 쓰면 조용히 틀린다 — '발행됨(1)' 을 눌렀는데 '내림' 배지가 낀 줄이 같이
* 나오는 종류다. 건수까지 틀리므로 사장님은 목록을 못 믿게 된다.
* ★ 세 칸이 목록을 빈틈없이 나눈다. 하나라도 빠지면 '전체' 건수와 칸 건수의 합이 어긋난다.
*/
type SiteBucket = 'live' | 'draft';
type SiteFilter = SiteBucket | 'all';
/**
* ★ 칸은 **둘**이다. 예전엔 '만드는 중'(사이트 행 없음)을 따로 뒀는데, 사장님에게 그 둘은
* 같은 상태다 — "아직 안 나가 있다". `site_id` 가 있고 없고는 우리 DB 사정이지 사장님의
* 구분이 아니고, 칸이 셋이면 34개가 어디 있는지 두 번 세게 된다.
*/
const FILTER_LABEL: Record<SiteFilter, string> = {
all: '전체',
live: '발행됨',
draft: '발행 전',
};
const FILTER_TABS: readonly SiteFilter[] = ['all', 'live', 'draft'];
/** 나가 있는 것부터 본다 — 실측(계정 test): 35개 중 34개가 발행 전이라 1개가 묻힌다. */
const BUCKET_ORDER: Record<SiteBucket, number> = {live: 0, draft: 1};
function bucketOf(row: MySiteData): SiteBucket {
return row.site_id && row.status === SiteStatus.PUBLISHED ? 'live' : 'draft';
}
/** /**
* 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는 * 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는
* 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다. * 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다.
*/ */
function statusBadge(row: MySiteData) { function statusBadge(row: MySiteData) {
if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const}; if (bucketOf(row) === 'live') {
switch (row.status) { return row.needs_rebuild
case SiteStatus.PUBLISHED: ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
return row.needs_rebuild : {label: '발행됨', variant: 'success' as const};
? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
: {label: '발행됨', variant: 'success' as const};
case SiteStatus.SUSPENDED:
return {label: '중지', variant: 'outline' as const};
case SiteStatus.UNPUBLISHED:
return {label: '내림', variant: 'outline' as const};
default:
return {label: '발행 전', variant: 'default' as const};
} }
// 같은 '발행 전' 이어도 **한 번 나갔다가 내린 것**은 말해 준다 — 되돌리는 일과 처음 내는 일은
// 사장님이 할 행동이 다르다. 그 외(초안·수집 중)는 전부 '발행 전' 한 마디다.
if (row.status === SiteStatus.SUSPENDED) return {label: '중지', variant: 'outline' as const};
if (row.status === SiteStatus.UNPUBLISHED) return {label: '내림', variant: 'outline' as const};
return {label: '발행 전', variant: 'default' as const};
} }
/** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */ /** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */
@ -58,19 +90,135 @@ function publishedUrl(row: MySiteData): string | null {
return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST); return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST);
} }
/**
* 줄 뒤에 붙는 시각. **발행됐으면 발행일, 아니면 만든 날**이다.
*
* ★ 두 개를 같이 걸지 않는다. Wix·아임웹 목록이 시각을 한 칸만 쓰는 이유와 같다 —
* 목록에서 궁금한 건 "이게 언제 나갔나" 하나이고, 아직 안 나간 줄에만 만든 날이 의미가 있다.
* ★ 연도는 올해면 뗀다. 줄이 좁아 주소가 먼저 잘린다.
*/
function whenLabel(row: MySiteData): string {
const raw = row.published_at ?? row.created_at;
if (!raw) return '';
const at = new Date(raw);
if (Number.isNaN(at.getTime())) return '';
const now = new Date();
const date = at.toLocaleDateString('ko-KR', {
...(at.getFullYear() === now.getFullYear() ? {} : {year: 'numeric'}),
month: 'long',
day: 'numeric',
});
return row.published_at ? `${date} 발행` : `${date} 만듦`;
}
/** 정렬용 시각. whenLabel 이 고른 값과 같은 것을 쓴다 — 화면에 보이는 날짜와 순서가 갈라지면 안 된다. */
function rowTime(row: MySiteData): number {
const raw = row.published_at ?? row.created_at;
if (!raw) return 0;
const at = new Date(raw).getTime();
return Number.isNaN(at) ? 0 : at;
}
/**
* 검색 비교용 정규화. **공백을 지운다** — 실측(계정 test)에 같은 상호 '버터브루' 가 4줄인데
* 사장님은 '버터 브루' 로도 친다. 한국어 상호는 띄어쓰기가 원본마다 다르다.
*/
function normalizeText(value: string): string {
return value.toLowerCase().replace(/\s+/g, '');
}
/** 상호와 도로명 주소만 본다 — 상호가 겹치는 줄을 실제로 가르는 건 주소다. */
function matchesQuery(row: MySiteData, needle: string): boolean {
if (!needle) return true;
return normalizeText(row.name).includes(needle) || normalizeText(row.road_address ?? '').includes(needle);
}
/**
* 줄 앞의 그림. 발행에 성공한 사이트만 썸네일이 있고(sites.thumbnail_url),
* 없으면 업종 아이콘으로 떨어진다.
*
* ★ 주소에 `?v=<버전>` 이 붙어 있다 — 발행할 때마다 바뀐다(site_thumbnail.public_url).
* 그래서 <img> 에 캐시 무효화를 따로 걸지 않는다. 여기서 또 붙이면 발행하지 않은
* 재방문에도 매번 새로 받는다.
* ★ 못 받으면 아이콘으로 되돌린다. 블롭이 지워졌거나 옛 주소인 줄에서 깨진 그림이
* 뜨는 것보다 낫다.
*/
function SiteThumb({row, Icon}: {row: MySiteData; Icon: typeof Building2}) {
const [failed, setFailed] = useState(false);
const src = row.thumbnail_url;
// ★ 비율은 **16:10 — 브라우저 창 비율**이다. 이 그림은 사이트 미리보기라 1:1 로 자르면
// 위아래가 잘려 무슨 사이트인지 알아볼 수 없다(사진첩이 아니다).
// ★ 크기는 아임웹 내사이트 화면을 보고 키웠다(2026-09-08). 거기 썸네일도 줄 높이의 대부분을
// 차지한다 — 같은 상호가 여러 줄일 때 그림이 유일한 구분자인데 작으면 있으나 마나다.
if (!src || failed) {
return (
<div className="flex aspect-[16/10] w-full items-center justify-center border-b border-border bg-muted/60">
<Icon className="size-7 text-muted-foreground/60" />
</div>
);
}
return (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className="aspect-[16/10] w-full border-b border-border object-cover"
/>
);
}
/** /**
* 내 사이트 — 로그인한 사장님의 홈이다. * 내 사이트 — 로그인한 사장님의 홈이다.
* *
* 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다. * 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다.
* ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다. * ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다.
*
* ★ 목록이 하는 일은 셋뿐이다 — ① 어느 게 어느 건지 가르고 ② 지금 상태를 말하고 ③ 여는 길을 준다.
* 방문자·주문 같은 숫자는 여기 넣지 않는다(Wix·아임웹도 사이트 안 대시보드에 둔다).
*/ */
export function SitesPage() { export function SitesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const {data, isLoading, isError, error, refetch} = useListMySites({size: 50}); const {data, isLoading, isError, error, refetch} = useListMySites({size: 50});
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [menuId, setMenuId] = useState<string | null>(null); const [menuId, setMenuId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<SiteFilter>('all');
const rows = data?.sites ?? []; const rows = useMemo(() => data?.sites ?? [], [data]);
// ★ 서버로 안 보낸다. 한 번에 50줄을 이미 다 받아 놓았고(실측 35줄), 서버 검색을 붙이면
// 글자마다 왕복 + 디바운스 + 늦게 온 응답이 최신 결과를 덮는 경합까지 따라온다.
// → 50줄을 넘어 페이지가 생기는 날이 오면 그때는 서버가 맞다(이 필터는 첫 페이지만 본다).
const found = useMemo(() => {
const needle = normalizeText(query);
return rows.filter((row) => matchesQuery(row, needle));
}, [rows, query]);
// ★ 건수는 **검색 결과 위에서** 센다. 검색어를 친 뒤 "발행됨 0 · 만드는 중 3" 이 보여야
// 찾는 게 어느 칸에 있는지 알 수 있다. 검색 전 건수를 그대로 두면 칸을 눌러 보고서야 안다.
const counts = useMemo(() => {
const tally: Record<SiteFilter, number> = {all: found.length, live: 0, draft: 0};
for (const row of found) tally[bucketOf(row)] += 1;
return tally;
}, [found]);
// 서버는 사업장 생성 역순으로만 준다(site_crud.list_owner_sites) — 발행 여부를 모른다.
// 나가 있는 것을 위로 올리는 건 여기서 한다.
const visible = useMemo(() => {
const picked = filter === 'all' ? found : found.filter((row) => bucketOf(row) === filter);
return [...picked].sort(
(a, b) => BUCKET_ORDER[bucketOf(a)] - BUCKET_ORDER[bucketOf(b)] || rowTime(b) - rowTime(a),
);
}, [found, filter]);
const isNarrowed = query.trim().length > 0 || filter !== 'all';
const resetView = () => {
setQuery('');
setFilter('all');
};
// 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면 // 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면
// 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석). // 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석).
@ -135,53 +283,145 @@ export function SitesPage() {
/> />
)} )}
{rows.length > 0 && ( {!isLoading && !isError && rows.length > 0 && (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card"> <div className="mb-3 flex flex-wrap items-center gap-2">
{rows.map((row) => { <div className="relative min-w-56 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="상호나 주소로 찾기"
aria-label="상호나 주소로 찾기"
className="h-9 pr-8 pl-8"
/>
{query && (
<button
type="button"
aria-label="검색어 지우기"
onClick={() => setQuery('')}
className="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
{/* 건수를 배지가 아니라 칸 안에 붙인다 — "34가 어디 있나"는 칸을 눌러 보기 전에 보여야 한다. */}
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
{FILTER_TABS.map((key) => (
<button
key={key}
type="button"
aria-pressed={filter === key}
onClick={() => setFilter(key)}
className={cn(
'h-8 cursor-pointer rounded px-2.5 text-xs font-medium transition-colors',
filter === key
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{FILTER_LABEL[key]}
<span className="ml-1 tabular-nums opacity-60">{counts[key]}</span>
</button>
))}
</div>
</div>
)}
{/* ★ 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면
사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */}
{!isLoading && !isError && rows.length > 0 && visible.length === 0 && (
<EmptyState
icon={SearchX}
title="조건에 맞는 사이트가 없습니다"
description={`가진 사이트 ${rows.length}개 중에 없습니다. 검색어를 줄이거나 다른 칸을 보세요.`}
action={
<Button size="sm" onClick={resetView}>
조건 지우기
</Button>
}
/>
)}
{visible.length > 0 && (
/* ★ 줄이 아니라 카드다. 아임웹 내사이트 화면을 보고 바꿨다(2026-09-08) — 거기 썸네일은
줄 높이의 대부분을 차지한다. 사이트 목록에서 그림은 장식이 아니라 **유일한 구분자**라
(실측: 같은 상호 '버터브루' 4줄) 작으면 있으나 마나다.
비율은 16:10 — 사이트 미리보기라 브라우저 창 비율이어야 한다. 1:1 은 사진첩이지 사이트가 아니다. */
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((row) => {
const Icon = CATEGORY_ICON[row.category] ?? Building2; const Icon = CATEGORY_ICON[row.category] ?? Building2;
const badge = statusBadge(row); const badge = statusBadge(row);
const url = publishedUrl(row); const url = publishedUrl(row);
const isLive = bucketOf(row) === 'live';
const editHref = `/builder?placeId=${row.place_id}`; const editHref = `/builder?placeId=${row.place_id}`;
return ( return (
<li key={row.place_id} className="relative flex items-center gap-3 px-4 py-3.5 hover:bg-muted/40"> /* ★ 카드는 **상태와 무관하게 같은 골격**이다. 예전엔 발행 전 카드에만 '할 일'
<Icon className="size-4 shrink-0 text-muted-foreground" /> 줄이 하나 더 붙어서 같은 줄의 카드끼리 높이와 버튼 위치가 어긋났다(사장님 지적).
h-full + flex-col + mt-auto 로 액션 줄을 항상 카드 바닥에 붙인다. */
<Link to={editHref} className="min-w-0 flex-1"> <li
<div className="flex items-center gap-2"> key={row.place_id}
<span className="truncate text-sm font-semibold">{row.name}</span> className="group relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-shadow hover:shadow-md"
<Badge variant={badge.variant}>{badge.label}</Badge> >
</div> <Link to={editHref} className="block">
<p className="mt-0.5 truncate text-xs text-muted-foreground"> <SiteThumb row={row} Icon={Icon} />
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
</p>
</Link> </Link>
<div className="flex shrink-0 items-center gap-1.5"> {/* 배지는 그림 위에 얹는다 — 카드에서 상태는 제목보다 먼저 읽혀야 한다. */}
{url && ( <Badge variant={badge.variant} className="absolute top-2.5 left-2.5 shadow-sm">
<a {badge.label}
href={url} </Badge>
target="_blank"
rel="noopener noreferrer" <div className="flex flex-1 flex-col p-3.5">
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted" <Link to={editHref} className="block min-w-0">
<p className="truncate text-sm font-semibold">{row.name}</p>
{/* ★ 나가 있는 카드만 주소를 진하게. 나머지의 이 자리는 "아직 없다"는 안내라
같은 색이면 34개의 안내문 사이에 진짜 주소가 묻힌다. */}
<p
className={cn(
'mt-1 truncate text-xs',
isLive ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
> >
<ExternalLink className="size-3.5" /> {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
사이트 열기 </p>
</a> {/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */}
)} <p className="mt-1 mb-3 truncate text-[11px] text-muted-foreground/70">
<Button size="sm" onClick={() => navigate(editHref)}> {[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')}
<Pencil /> </p>
{row.site_id ? '편집' : '이어서 만들기'} </Link>
</Button>
<Button <div className="mt-auto flex items-center gap-1.5 border-t border-border pt-3">
size="icon" {/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이
variant="ghost" 같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */}
aria-label="더보기" <Button size="sm" className="flex-1" onClick={() => navigate(editHref)}>
isLoading={busyId === row.place_id} <Pencil />
onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)} 편집
> </Button>
{busyId === row.place_id ? null : <MoreHorizontal />} {url && (
</Button> <a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ExternalLink className="size-3.5" />
열기
</a>
)}
<Button
size="icon"
variant="ghost"
aria-label="더보기"
isLoading={busyId === row.place_id}
onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)}
>
{busyId === row.place_id ? null : <MoreHorizontal />}
</Button>
</div>
</div> </div>
{menuId === row.place_id && ( {menuId === row.place_id && (
@ -193,7 +433,7 @@ export function SitesPage() {
className="fixed inset-0 z-10 cursor-default" className="fixed inset-0 z-10 cursor-default"
onClick={() => setMenuId(null)} onClick={() => setMenuId(null)}
/> />
<div className="absolute right-4 top-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md"> <div className="absolute right-3.5 bottom-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md">
<button <button
type="button" type="button"
disabled={row.status !== SiteStatus.PUBLISHED} disabled={row.status !== SiteStatus.PUBLISHED}
@ -210,6 +450,7 @@ export function SitesPage() {
})} })}
</ul> </ul>
)} )}
</PageContainer> </PageContainer>
</AppShell> </AppShell>
); );