diff --git a/README.md b/README.md index a4ad3b6bb7..fc28cecca4 100644 --- a/README.md +++ b/README.md @@ -73,45 +73,31 @@ Tzudong Map turns mukbang video evidence into a usable restaurant map: users dis -## Local architecture +## Architecture -System map: [interactive system architecture](docs/architecture/tzudong-system-architecture.html) · [Archify source](docs/architecture/tzudong-system-architecture.archify.json) +Public map, admin, and the evidence pipeline meet in Supabase. Long work stays off the web request. -Worker admission flow: [interactive worker boosting flow](docs/architecture/tzudong-worker-boosting.workflow.html) · [Archify source](docs/architecture/tzudong-worker-boosting.workflow.archify.json) +```mermaid +flowchart LR + web["apps/web"] --> db[("Supabase")] + ops["CI / cron"] --> control["pipeline-api"] + control --> worker["pipeline-worker"] + worker --> crawl["crawler / evaluation"] + crawl --> db + crawl --> ext["External providers"] + worker --> db +``` -Interactive workflow: [storyboard-local-mlx.html](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.html) +Open a diagram to pan, zoom, and follow a path. Sources sit next to each HTML file. -Workflow source: [storyboard-local-mlx.workflow.json](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.workflow.json) - -Interactive lifecycle: [storyboard-local-mlx.lifecycle.html](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.lifecycle.html) - -Lifecycle source: [storyboard-local-mlx.lifecycle.json](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.lifecycle.json) - -Interactive map discovery: [map-discovery.html](docs/architecture/map-discovery/map-discovery.html) - -Live browser captures: [workflow](docs/operations/evidence/storyboard-restore-20260920/archify-storyboard-workflow-desktop.png) · [lifecycle](docs/operations/evidence/storyboard-restore-20260920/archify-storyboard-lifecycle-desktop.png) · [map discovery](docs/operations/evidence/storyboard-restore-20260920/archify-map-discovery-desktop.png) - -Map discovery source: [map-discovery.workflow.json](docs/architecture/map-discovery/map-discovery.workflow.json) - -Evidence pipeline: [interactive data pipeline](docs/architecture/data-pipeline/tzudong-data-pipeline.html) · [dataflow source](docs/architecture/data-pipeline/tzudong-data-pipeline.dataflow.json) - -Worker flow: [interactive worker boosting flow](docs/architecture/data-pipeline/worker-boosting.html) · [workflow source](docs/architecture/data-pipeline/worker-boosting.workflow.json) - -The worker keeps image concurrency at `c=1`. A future bounded boost must pass memory admission and observed queue/run/RSS evidence before changing that cap; dynamic concurrency increase is not claimed as implemented. Queue claim and lease ownership, heartbeat freshness, revision checks, conditional save/readback, and late-write rejection define the persistence boundary. Operator observations are bounded and non-authoritative, with secrets and raw payloads excluded. - -The local workspace separates **new project creation**, **scene editing**, **version history**, and **manual result import**. Saved projects open directly on the scene editor; connection diagnostics are under Settings. Text and image providers remain independent: local MLX, ChatGPT manual import, and Grok manual import are selectable with external AI off. OpenAI/xAI official APIs stay gated and currently unconfigured. There is no automatic cloud fallback. - -Storyboard provider policy keeps `local-mlx`, `chatgpt-manual`, and `grok-manual` independently selectable for text and image without requiring an official external API; the manual choices return through user import. The `externalAI` consent gate applies only to the official API provider IDs `openai-api` and `xai-api`. - -Historical restoration now uses immutable DB snapshots. Version preview and a confirmed whole-project or single-scene restore preserve the historical text, scene order, asset references, provenance and original image bytes. Restoration writes a new revision without calling a model or queueing a job; current provider settings and consent are retained. Applied projects start history from the state captured by the migration, not from invented earlier versions. Owner/admin checks, current revision, busy-project rejection and idempotent request IDs protect the operation. - -The memory admission model is implemented in `apps/web/lib/admin/storyboard/resource-invariants.ts`: `used + additional_peak_estimate + reserve <= physical`, with initial reserve `max(16 GiB, 12.5% of physical RAM)`. The worker now uses host-wide `os.freemem()` by default and only adds model residency in the explicit RSS fallback; measurements and peak estimates are not absolute guarantees. The [runtime audit on 2026-09-21](docs/operations/evidence/storyboard-restore-20260920/runtime-audit-20260921.md) records the remaining stress-test and peak-inference limits. The queue-wait helper is currently unit-tested but has no runtime caller. Image concurrency starts at one. Queue wait follows `W[i+1] = max(0, W[i] + S[i] - A[i])`; absent a live worker, estimated wait is unknown. Atomic claim, DB-clock leases, heartbeat, current-job ownership and project/scene revisions reject stale writes. Cancellation forbids late writes; retry is explicit. Original assets are immutable. The Archify viewer's fixed controls fall back to English for Korean-authored diagrams. - -The [2026-09-21 architecture and goal audit](docs/operations/evidence/storyboard-restore-20260920/architecture-goal-audit-20260921.md) joins the MLX, queue/lease, FSM, memory, map/filter, and hosted-feed readback evidence without treating hosted writes or deployment as complete. - -**Local evidence (2026-09-20):** real-model project v12 → edit v13 → scene restore v14 → full undo v15 → full restore v16. All 20 exported PNG/WebP files matched their original hashes and decoded successfully; these three restores created zero jobs. See the [verification report and responsive screenshots](docs/operations/evidence/storyboard-restore-20260920/README.md). Local migration and UI verification do not establish hosted migration, external Web review or production deployment. +| Diagram | Open | +| --- | --- | +| System map | [tzudong-system-architecture.html](docs/architecture/tzudong-system-architecture.html) | +| Map discovery | [map-discovery.html](docs/architecture/map-discovery/map-discovery.html) | +| Evidence pipeline | [tzudong-data-pipeline.html](docs/architecture/data-pipeline/tzudong-data-pipeline.html) | +| Storyboard workflow | [storyboard-local-mlx.html](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.html) | -**Map evidence (2026-09-20, updated):** Seoul cluster expand kept individual markers and opened the restaurant list (66 places after the hot-view filter). YouTube play control stayed centered on 16:9 `sddefault`. GPS remains fail-closed with `DEVICE_LOCATION_OPERATOR_EVIDENCE_REQUIRED`. In the explicit hosted development mode, local `/feed` was rechecked on 2026-09-21 against the hosted Supabase project: the public feed rendered two verified reviews for 데일리픽스 and 스시린 under `쯔동마스터`, with no Nightly identity. The default local command remains isolated from hosted data. See [hosted readback and limitations](docs/operations/evidence/storyboard-restore-20260920/feed-local-readback-20260921.md). Theme chips live-rechecked: hot-view 125 (Seoul cluster 65 → list 66), comment-hot 125 (Seoul 65), fan-signal 63 (Seoul 32), recent-video 32 (Seoul 17), repeat 20 (Seoul cluster 12 → list 13), using hosted-read YouTube KPI snapshots copied locally (not invented). Public overlay/map panel elevation is `shadow-sm`; dropdown menus keep their `shadow-2xl` contract. Mobile `/feed` hides the write FAB while login or detail sheets are open. Desktop/390/320 screenshots: [public UI evidence](docs/operations/evidence/storyboard-restore-20260920/). Hosted deployment and protected promotion are still separate. +Promotion is `develop -> data -> main`. A production build runs only when `TZUDONG_APPROVED_PRODUCTION_SHA` matches that commit. Detail lives in [docs/README.md](docs/README.md). ### Measured query and normalization optimizations (2026-09-21) diff --git a/apps/web/.env.example b/apps/web/.env.example index 6c3716e7c4..8006a22d82 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -121,18 +121,6 @@ THUMBNAIL_AGENT_ROOT=../../backend/thumbnail-agent THUMBNAIL_AGENT_PYTHON=python3 THUMBNAIL_AGENT_RUNTIME=local_graph THUMBNAIL_AGENT_TIMEOUT_MS=120000 -# Device-location release evidence (server-only; default is blocked). -# Exact evidence references are operator inputs, not a legal-compliance flag. -DEVICE_LOCATION_RELEASE_DECISION= -# Allowed only: not_applicable_verified | filing_receipt_verified -DEVICE_LOCATION_EXTERNAL_STATUS= -# Each evidence reference is a SHA-256 digest (64 lowercase hex characters). -DEVICE_LOCATION_OPERATOR_EVIDENCE_HASH= -DEVICE_LOCATION_PROVIDER_EVIDENCE_HASH= -DEVICE_LOCATION_EXTERNAL_EVIDENCE_HASH= -DEVICE_LOCATION_LEGAL_EVIDENCE_HASH= -# Canonical UTC ISO timestamp, for example 2026-07-12T00:00:00.000Z -DEVICE_LOCATION_RELEASE_CONFIRMED_AT= # Keyed minimization for request IP/user-agent audit references (server-only, >=32 bytes). PRIVACY_AUDIT_HASH_KEY= # Durable account-deletion worker: independent server-only capability (minimum 32 UTF-8 bytes). diff --git a/apps/web/app/admin/banners/page.tsx b/apps/web/app/admin/banners/page.tsx index f4f5d1ca57..6eee4c54eb 100644 --- a/apps/web/app/admin/banners/page.tsx +++ b/apps/web/app/admin/banners/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useRef, useMemo, Suspense, useEffect } from 'react'; +import { useState, useRef, useMemo, Suspense, useEffect, useLayoutEffect } from 'react'; +import { useFilledSkeletonCount } from '@/lib/use-filled-skeleton-count'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; @@ -120,13 +121,14 @@ const isNestedUploadInteractiveTarget = (target: EventTarget | null) => { type BannerManagementPageWrapperProps = { embedded?: boolean; + onInitialContentReady?: () => void; }; // Suspense 래퍼 -function BannerManagementPageWrapper({ embedded = false }: BannerManagementPageWrapperProps = {}) { +function BannerManagementPageWrapper({ embedded = false, onInitialContentReady }: BannerManagementPageWrapperProps = {}) { return ( - + ); } @@ -140,12 +142,19 @@ BannerManagementRoutePage.Embedded = BannerManagementPageWrapper; export default BannerManagementRoutePage; -function BannerManagementPage({ embedded }: Required) { +function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagementPageWrapperProps & { embedded: boolean }) { const router = useRouter(); const { user, isAdmin, isLoading: authLoading } = useAuth(); // 배너 데이터 const { data: banners = [], isLoading: bannersLoading } = useAdBannersAdmin(); + const initialLoadPendingRef = useRef(true); + if (initialLoadPendingRef.current && !authLoading && !bannersLoading) initialLoadPendingRef.current = false; + useLayoutEffect(() => { + if (!onInitialContentReady || authLoading || initialLoadPendingRef.current) return; + onInitialContentReady(); + }, [authLoading, bannersLoading, onInitialContentReady]); + const bannerListSkeleton = useFilledSkeletonCount(88, 5); const createBanner = useCreateAdBanner(); const updateBanner = useUpdateAdBanner(); const deleteBanner = useDeleteAdBanner(); @@ -547,7 +556,8 @@ function BannerManagementPage({ embedded }: Required )} @@ -592,11 +602,11 @@ function BannerManagementPage({ embedded }: Required -
+
{bannersLoading ? (
배너 목록 데이터를 불러오는 중입니다. - {Array.from({ length: 5 }).map((_, index) => )} + {Array.from({ length: bannerListSkeleton.count }).map((_, index) => )}
) : sortedBanners.length === 0 ? ( diff --git a/apps/web/app/admin/evaluations/page.tsx b/apps/web/app/admin/evaluations/page.tsx index 3ea9da9af8..317352262e 100644 --- a/apps/web/app/admin/evaluations/page.tsx +++ b/apps/web/app/admin/evaluations/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react'; +import { useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, Suspense } from 'react'; +import { useFilledSkeletonCount } from '@/lib/use-filled-skeleton-count'; import { useRouter, useSearchParams } from 'next/navigation'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; @@ -1314,6 +1315,7 @@ type AdminEvaluationPageWrapperProps = { embedded?: boolean; initialView?: 'evaluations' | 'submissions'; initialSubmissionTab?: 'new' | 'edit' | 'recommend' | 'reviews'; + onInitialContentReady?: () => void; }; // Suspense 래퍼 컴포넌트 @@ -1321,6 +1323,7 @@ function AdminEvaluationPageWrapper({ embedded = false, initialView = 'evaluations', initialSubmissionTab, + onInitialContentReady, }: AdminEvaluationPageWrapperProps = {}) { return ( }> @@ -1328,6 +1331,7 @@ function AdminEvaluationPageWrapper({ embedded={embedded} initialView={initialView} initialSubmissionTab={initialSubmissionTab} + onInitialContentReady={onInitialContentReady} /> ); @@ -1402,10 +1406,10 @@ function AdminEvaluationStaticMobileLoadingControls() { ); } -function AdminEvaluationStaticCardSkeleton() { +function AdminEvaluationStaticCardSkeleton({ count }: { count: number }) { return ( -