[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:
parent
94551afdaf
commit
9f16c3224b
@ -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 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다
|
||||||
|
|
||||||
**무슨 일**
|
**무슨 일**
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}",
|
||||||
|
|||||||
@ -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)
|
|
||||||
container.upload_blob(
|
|
||||||
name=name,
|
|
||||||
data=data,
|
|
||||||
overwrite=True,
|
|
||||||
# cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
|
# cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
|
||||||
content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL),
|
container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings)
|
||||||
)
|
|
||||||
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)
|
||||||
|
|||||||
@ -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건."""
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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) {
|
|
||||||
case SiteStatus.PUBLISHED:
|
|
||||||
return row.needs_rebuild
|
return row.needs_rebuild
|
||||||
? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
|
? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
|
||||||
: {label: '발행됨', variant: 'success' 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,29 +283,124 @@ 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 로 액션 줄을 항상 카드 바닥에 붙인다. */
|
||||||
|
<li
|
||||||
|
key={row.place_id}
|
||||||
|
className="group relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<Link to={editHref} className="block">
|
||||||
|
<SiteThumb row={row} Icon={Icon} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link to={editHref} className="min-w-0 flex-1">
|
{/* 배지는 그림 위에 얹는다 — 카드에서 상태는 제목보다 먼저 읽혀야 한다. */}
|
||||||
<div className="flex items-center gap-2">
|
<Badge variant={badge.variant} className="absolute top-2.5 left-2.5 shadow-sm">
|
||||||
<span className="truncate text-sm font-semibold">{row.name}</span>
|
{badge.label}
|
||||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
</Badge>
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
<div className="flex flex-1 flex-col p-3.5">
|
||||||
|
<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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
|
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
|
||||||
</p>
|
</p>
|
||||||
|
{/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */}
|
||||||
|
<p className="mt-1 mb-3 truncate text-[11px] text-muted-foreground/70">
|
||||||
|
{[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="mt-auto flex items-center gap-1.5 border-t border-border pt-3">
|
||||||
|
{/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이
|
||||||
|
같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */}
|
||||||
|
<Button size="sm" className="flex-1" onClick={() => navigate(editHref)}>
|
||||||
|
<Pencil />
|
||||||
|
편집
|
||||||
|
</Button>
|
||||||
{url && (
|
{url && (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
@ -166,13 +409,9 @@ export function SitesPage() {
|
|||||||
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"
|
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" />
|
<ExternalLink className="size-3.5" />
|
||||||
사이트 열기
|
열기
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" onClick={() => navigate(editHref)}>
|
|
||||||
<Pencil />
|
|
||||||
{row.site_id ? '편집' : '이어서 만들기'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -183,6 +422,7 @@ export function SitesPage() {
|
|||||||
{busyId === row.place_id ? null : <MoreHorizontal />}
|
{busyId === row.place_id ? null : <MoreHorizontal />}
|
||||||
</Button>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user