kintex/mobile/app/(tabs)/gallery.tsx
zio 490b723a45 chore(mobile): v0.2.0 source snapshot for reproducibility
- app.json: version 0.2.0 / versionCode 2, drop expo-screen-capture plugin
- add lib/i18n (ko/en/ja/zh), lib/roleTrack, LanguageContext + LanguageSelector
- profile/security hardening across screens (login/register/forgot/profile/tabs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:29:38 +09:00

157 lines
4.8 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, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
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 폴백 샘플 (서버 미구현/오프라인 시 표시 — 항상 워터마크 유지)
function buildFallback(notice: string): RenderJobDto[] {
return [
mkJob('job-s1', 'S1', 'DONE', notice),
mkJob('job-s2', 'S2', 'DONE', notice),
mkJob('job-s3', 'S3', 'RUNNING', notice),
mkJob('job-s7', 'S7', 'FAILED', notice),
];
}
function mkJob(
jobId: string,
shot: string,
status: RenderJobDto['status'],
notice: string,
): RenderJobDto {
return {
jobId,
boothId: DEMO_BOOTH,
shotPreset: shot,
status,
imageUrl: null,
schemaHash: null,
modelVersion: null,
watermarkRequired: true,
watermarkText: AI_IMAGE_NOTICE,
notice,
};
}
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 { t } = useTranslation();
const { activeWorkspace, token } = useAuth();
const [jobs, setJobs] = useState<RenderJobDto[]>([]);
const [degraded, setDegraded] = useState(false);
const [loading, setLoading] = useState(true);
const fallback = useMemo(() => buildFallback(t('gallery.notice')), [t]);
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, fallback]);
useEffect(() => {
load();
}, [load]);
return (
<View style={styles.flex}>
{/* 상단 고정 고지 바 (제거 불가) */}
<View style={styles.infoBar}>
<Text style={styles.infoBarText}>{t('gallery.infoBar')}</Text>
</View>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} tintColor={colors.primary600} />}
>
{degraded ? (
<Banner tone="degraded">{t('gallery.degraded')}</Banner>
) : null}
{jobs.length === 0 && !loading ? (
<Banner tone="info">{t('gallery.empty')}</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={t('gallery.retry')} variant="outline" onPress={load} style={styles.retry} />
) : null}
</View>
))}
</View>
<Button
label={t('gallery.studioNote')}
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 },
});