- Harness: kintex-mobile-dev agent + kintex-mobile-orchestrator skill (WISE mobile ref, Stitch-first design rule, dual app targets B2B/B2C) - design.md v2.1: full 84-screen inventory (web 51 / admin 10 / public 8 / mobile 15) with Stitch prompts incl. ticketing (SCR-P7/P8, M14/M15) - PLANNING v3.1: unified account + split signup tracks (2FA required for staff, light signup/guest for visitors), one codebase / two app targets - Deliverables: dev plan (21s), user/operator/developer guides (17/14/15s), program spec (44s, 65 programs, 8 flowcharts), DA (DB design 14s + table spec xlsx 35 tables/299 cols) - Benchmark: ticketing-app-benchmark.md (7 apps) -> IMPLEMENTATION_BACKLOG Phase F (14 items) - Stitch: 23 generated screens saved (mobile 10, admin 6, web core 5, ticket 2) - mobile/: Expo scaffold (SDK 51, expo-router, secure store JWT) - frontend: SCR-13~17 QA fixes, icons.tsx, kintexEvents, V10 seed migration - ci/: KINTEX CI logo assets Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
/*
|
|
* SCR-12 시각화 갤러리 (모바일 2열) — 워터마크 상시 + 상단 고정 고지 바.
|
|
* 계약: GET /api/events/{eventId}/booths/{boothId}/render-jobs?shot={S1} (스켈레톤 501 → degraded 폴백).
|
|
* 실 API 호출 구조를 유지하되, 서버 미구현/오프라인 시 degraded 샘플로 폴백한다.
|
|
*/
|
|
import { router } from 'expo-router';
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
import { AiImage } from '../../components/AiImage';
|
|
import { Banner } from '../../components/Banner';
|
|
import { Button } from '../../components/Button';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
import { api, isDegraded } from '../../lib/api';
|
|
import { AI_IMAGE_NOTICE } from '../../lib/config';
|
|
import type { RenderJobDto } from '../../lib/types';
|
|
import { colors, spacing, type } from '../../theme';
|
|
|
|
const DEMO_BOOTH = 'b-102';
|
|
|
|
// degraded 폴백 샘플 (서버 미구현/오프라인 시 표시 — 항상 워터마크 유지)
|
|
const FALLBACK: RenderJobDto[] = [
|
|
mkJob('job-s1', 'S1', 'DONE'),
|
|
mkJob('job-s2', 'S2', 'DONE'),
|
|
mkJob('job-s3', 'S3', 'RUNNING'),
|
|
mkJob('job-s7', 'S7', 'FAILED'),
|
|
];
|
|
|
|
function mkJob(jobId: string, shot: string, status: RenderJobDto['status']): RenderJobDto {
|
|
return {
|
|
jobId,
|
|
boothId: DEMO_BOOTH,
|
|
shotPreset: shot,
|
|
status,
|
|
imageUrl: null,
|
|
schemaHash: null,
|
|
modelVersion: null,
|
|
watermarkRequired: true,
|
|
watermarkText: AI_IMAGE_NOTICE,
|
|
notice: 'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 (시공 기준은 도면)',
|
|
};
|
|
}
|
|
|
|
const SHOT_LABEL: Record<string, string> = {
|
|
S1: 'S1 정면',
|
|
S2: 'S2 야간',
|
|
S3: 'S3 통로',
|
|
S4: 'S4 내부',
|
|
S5: 'S5 비교',
|
|
S6: 'S6 배선',
|
|
S7: 'S7 홀 전경',
|
|
};
|
|
|
|
export default function GalleryScreen() {
|
|
const { activeWorkspace, token } = useAuth();
|
|
const [jobs, setJobs] = useState<RenderJobDto[]>([]);
|
|
const [degraded, setDegraded] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
if (!activeWorkspace || !token) {
|
|
setJobs(FALLBACK);
|
|
setDegraded(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
try {
|
|
const res = await api.get<RenderJobDto[]>(
|
|
`/api/events/${activeWorkspace.eventId}/booths/${DEMO_BOOTH}/render-jobs`,
|
|
);
|
|
setJobs(res ?? []);
|
|
setDegraded(false);
|
|
} catch (e) {
|
|
if (isDegraded(e)) {
|
|
setJobs(FALLBACK);
|
|
setDegraded(true);
|
|
} else {
|
|
setJobs([]);
|
|
setDegraded(false);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [activeWorkspace, token]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
return (
|
|
<View style={styles.flex}>
|
|
{/* 상단 고정 고지 바 (제거 불가) */}
|
|
<View style={styles.infoBar}>
|
|
<Text style={styles.infoBarText}>
|
|
AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다
|
|
</Text>
|
|
</View>
|
|
|
|
<ScrollView
|
|
contentContainerStyle={styles.scroll}
|
|
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} tintColor={colors.primary600} />}
|
|
>
|
|
{degraded ? (
|
|
<Banner tone="degraded">
|
|
서버 이미지 이력에 연결할 수 없어 예시를 표시합니다 (degraded).
|
|
</Banner>
|
|
) : null}
|
|
|
|
{jobs.length === 0 && !loading ? (
|
|
<Banner tone="info">
|
|
아직 생성된 이미지가 없습니다 — 설계 스튜디오에서 예상 사진을 생성하세요.
|
|
</Banner>
|
|
) : null}
|
|
|
|
<View style={styles.grid}>
|
|
{jobs.map((j) => (
|
|
<View key={j.jobId} style={styles.cell}>
|
|
<AiImage
|
|
uri={j.imageUrl}
|
|
status={j.status}
|
|
watermarkText={j.watermarkText}
|
|
notice={j.notice}
|
|
shotBadge={SHOT_LABEL[j.shotPreset] ?? j.shotPreset}
|
|
height={130}
|
|
/>
|
|
{j.status === 'FAILED' ? (
|
|
<Button label="다시 생성" variant="outline" onPress={load} style={styles.retry} />
|
|
) : null}
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
<Button
|
|
label="설계 스튜디오는 데스크톱에서 편집"
|
|
variant="ghost"
|
|
onPress={() => router.push('/(tabs)')}
|
|
/>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
flex: { flex: 1, backgroundColor: colors.neutral050 },
|
|
infoBar: { backgroundColor: colors.primary700, paddingHorizontal: spacing.md, paddingVertical: 8 },
|
|
infoBarText: { color: colors.white, fontSize: type.caption.fontSize, fontWeight: '500' },
|
|
scroll: { padding: spacing.md, gap: spacing.md },
|
|
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
|
|
cell: { flexGrow: 1, flexBasis: '46%', gap: 6 },
|
|
retry: { minHeight: 40 },
|
|
});
|