Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
54 changes: 20 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,45 +73,31 @@ Tzudong Map turns mukbang video evidence into a usable restaurant map: users dis
</tr>
</table>

## 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)

Expand Down
12 changes: 0 additions & 12 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
24 changes: 17 additions & 7 deletions apps/web/app/admin/banners/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<Suspense fallback={null}>
<BannerManagementPage embedded={embedded} />
<BannerManagementPage embedded={embedded} onInitialContentReady={onInitialContentReady} />
</Suspense>
);
}
Expand All @@ -140,12 +142,19 @@ BannerManagementRoutePage.Embedded = BannerManagementPageWrapper;
export default BannerManagementRoutePage;


function BannerManagementPage({ embedded }: Required<BannerManagementPageWrapperProps>) {
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();
Expand Down Expand Up @@ -547,7 +556,8 @@ function BannerManagementPage({ embedded }: Required<BannerManagementPageWrapper
<div
className="fixed inset-0 opacity-30 pointer-events-none z-0"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.1'/%3E%3C/svg%3E")`,
backgroundImage: 'url("/images/ui-noise.png")',
backgroundRepeat: 'repeat',
}}
/>
)}
Expand Down Expand Up @@ -592,11 +602,11 @@ function BannerManagementPage({ embedded }: Required<BannerManagementPageWrapper
</div>
</div>

<div className="min-h-0 flex-1 space-y-2 overflow-y-auto scrollbar-hide p-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="list" aria-label="배너 목록">
<div ref={bannerListSkeleton.ref} className="min-h-0 flex-1 space-y-2 overflow-y-auto scrollbar-hide p-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="list" aria-label="배너 목록">
{bannersLoading ? (
<div className="space-y-2" role="status" aria-busy="true" aria-label="배너 목록 로딩 중">
<span className="sr-only">배너 목록 데이터를 불러오는 중입니다.</span>
{Array.from({ length: 5 }).map((_, index) => <BannerListItemSkeleton key={index} index={index} />)}
{Array.from({ length: bannerListSkeleton.count }).map((_, index) => <BannerListItemSkeleton key={index} index={index} />)}
</div>
) : sortedBanners.length === 0 ? (
<Card className="border-dashed border-border bg-background/70 p-4 text-center text-sm text-muted-foreground">
Expand Down
71 changes: 59 additions & 12 deletions apps/web/app/admin/evaluations/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -1314,20 +1315,23 @@ type AdminEvaluationPageWrapperProps = {
embedded?: boolean;
initialView?: 'evaluations' | 'submissions';
initialSubmissionTab?: 'new' | 'edit' | 'recommend' | 'reviews';
onInitialContentReady?: () => void;
};

// Suspense 래퍼 컴포넌트
function AdminEvaluationPageWrapper({
embedded = false,
initialView = 'evaluations',
initialSubmissionTab,
onInitialContentReady,
}: AdminEvaluationPageWrapperProps = {}) {
return (
<Suspense fallback={embedded ? null : <AdminEvaluationRouteSkeleton />}>
<AdminEvaluationPage
embedded={embedded}
initialView={initialView}
initialSubmissionTab={initialSubmissionTab}
onInitialContentReady={onInitialContentReady}
/>
</Suspense>
);
Expand Down Expand Up @@ -1402,10 +1406,10 @@ function AdminEvaluationStaticMobileLoadingControls() {
);
}

function AdminEvaluationStaticCardSkeleton() {
function AdminEvaluationStaticCardSkeleton({ count }: { count: number }) {
return (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:hidden" aria-hidden="true">
{Array.from({ length: 4 }).map((_, index) => (
<div className="grid min-h-0 flex-1 grid-cols-1 gap-2 md:grid-cols-2 lg:hidden" aria-hidden="true">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="rounded-2xl border border-border/70 bg-card/95 p-3 shadow-sm">
<div className="flex items-center gap-2">
<Skeleton className="h-12 w-16 shrink-0 rounded-md motion-reduce:animate-none" />
Expand All @@ -1425,6 +1429,22 @@ function AdminEvaluationStaticCardSkeleton() {
);
}
function AdminEvaluationRouteSkeleton() {
const frameRef = useRef<HTMLDivElement>(null);
const mobileCards = useFilledSkeletonCount(104, 4, 48);
const [rowCount, setRowCount] = useState(6);
useLayoutEffect(() => {
const node = frameRef.current;
if (!node) return;
const update = () => {
const height = node.clientHeight;
if (height <= 0) return;
setRowCount(Math.max(6, Math.ceil((height - 44) / 64)));
};
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div
role="status"
Expand Down Expand Up @@ -1457,10 +1477,10 @@ function AdminEvaluationRouteSkeleton() {
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 p-2">
<div ref={mobileCards.ref} className="flex min-h-0 flex-1 flex-col gap-3 p-2">
<AdminEvaluationStaticMobileLoadingControls />
<AdminEvaluationStaticCardSkeleton />
<div className="hidden min-h-0 overflow-hidden rounded-lg border bg-background lg:block">
<AdminEvaluationStaticCardSkeleton count={mobileCards.count} />
<div ref={frameRef} className="hidden min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-background lg:flex">
<div className="border-b bg-muted/35 lg:grid lg:grid-cols-[40px_minmax(180px,1fr)_repeat(6,78px)_112px]" aria-hidden="true">
{Array.from({ length: 9 }).map((_, index) => (
<div key={index} className="px-2 py-2">
Expand All @@ -1469,7 +1489,7 @@ function AdminEvaluationRouteSkeleton() {
))}
</div>
<div className="divide-y divide-border">
{Array.from({ length: 6 }).map((_, rowIndex) => (
{Array.from({ length: rowCount }).map((_, rowIndex) => (
<div
key={rowIndex}
className="grid items-center gap-2 p-2 lg:grid-cols-[40px_minmax(180px,1fr)_repeat(6,78px)_112px]"
Expand Down Expand Up @@ -1499,10 +1519,12 @@ function AdminEvaluationPage({
embedded,
initialView,
initialSubmissionTab,
onInitialContentReady,
}: {
embedded: boolean;
initialView: 'evaluations' | 'submissions';
initialSubmissionTab?: 'new' | 'edit' | 'recommend' | 'reviews';
onInitialContentReady?: () => void;
}) {
const { toast } = useToast();
const router = useRouter();
Expand Down Expand Up @@ -3477,16 +3499,41 @@ function AdminEvaluationPage({
},
});

// 인증 게이트는 전체 화면으로 막되, 데이터 로딩은 아래 실제 화면 요소별 스켈레톤으로 처리합니다.
if (!embedded && authLoading) {
return <AdminEvaluationRouteSkeleton />;
const initialContentLoading = initialView === 'submissions'
? submissionsLoading || recommendationRequestsLoading || reviewsLoading
: loading;
const initialLoadPendingRef = useRef(true);
if (
initialLoadPendingRef.current
&& !authLoading
&& (hasE2EAdminShellBypass || (user && isAdmin))
&& !initialContentLoading
) {
initialLoadPendingRef.current = false;
}

// 로그인하지 않았거나 관리자가 아닌 경우 (리다이렉트 전 화면 방지)
useLayoutEffect(() => {
if (!onInitialContentReady || authLoading) return;
if (!hasE2EAdminShellBypass && (!user || !isAdmin)) {
onInitialContentReady();
return;
}
if (initialLoadPendingRef.current) return;
onInitialContentReady();
}, [initialContentLoading, authLoading, onInitialContentReady, user, isAdmin, hasE2EAdminShellBypass]);

if (!hasE2EAdminShellBypass && !authLoading && (!user || !isAdmin)) {
return null;
}

if (!embedded && initialLoadPendingRef.current && (authLoading || initialContentLoading)) {
return <AdminEvaluationRouteSkeleton />;
}

if (embedded && initialLoadPendingRef.current && (authLoading || initialContentLoading)) {
return null;
}

const pendingRecordActionRequiredPhrase = EVALUATION_RESTORE_CONFIRMATION;
const pendingRecordActionVerb = '복원';
const pendingRecordActionName = pendingRecordAction
Expand Down
Loading
Loading