import {useEffect, useMemo, useState} from 'react'; import {CalendarDays, Check, Download, ExternalLink, MapPin, Search} from 'lucide-react'; import {PageContainer} from '@/components/layout/AppShell'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card, CardContent} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; import {customFetch} from '@/api/mutator/custom-fetch'; import {toast} from 'sonner'; type Status = 1 | 2 | 3; type LocalContent = { id: string; title: string; region: string; period: string; source: string; status: Status; selected: boolean; displayStart?: string; displayEnd?: string; imageUrl?: string; }; const STATUS_LABEL: Record = {1: '검수 대기', 2: '발행됨', 3: '종료됨'}; type ApiContent = { local_content_id: string; region_code: string; external_id?: string; title?: string; body: Record; status: Status; source: number; display_start_at?: string; display_end_at?: string; }; function formatDate(value: unknown) { const text = String(value ?? ''); return /^\d{8}$/.test(text) ? `${text.slice(0, 4)}. ${Number(text.slice(4, 6))}. ${Number(text.slice(6, 8))}` : text; } function toContent(item: ApiContent): LocalContent { const start = formatDate(item.body.eventstartdate); const end = formatDate(item.body.eventenddate); return { id: item.local_content_id, title: item.title || '제목 없음', region: String(item.body.addr1 || item.region_code), period: [start, end].filter(Boolean).join(' ~ ') || '기간 미정', source: `공공데이터포털 전국문화축제표준데이터 · ${item.external_id ?? '-'}`, status: item.status, selected: false, displayStart: item.display_start_at, displayEnd: item.display_end_at, imageUrl: String(item.body.image_url || item.body.firstimage || ''), }; } export function LocalContentPage() { const [contents, setContents] = useState([]); const [query, setQuery] = useState(''); const [syncing, setSyncing] = useState(false); const [loading, setLoading] = useState(true); const filtered = useMemo(() => contents.filter((item) => `${item.title} ${item.region}`.includes(query.trim())), [contents, query]); const selectedCount = contents.filter((item) => item.selected).length; const allFilteredSelected = filtered.length > 0 && filtered.every((item) => item.selected); const toggle = (id: string) => setContents((items) => items.map((item) => item.id === id ? {...item, selected: !item.selected} : item)); const toggleAll = () => { const visibleIds = new Set(filtered.map((item) => item.id)); setContents((items) => items.map((item) => visibleIds.has(item.id) ? {...item, selected: !allFilteredSelected} : item)); }; const load = async () => { setLoading(true); try { const res = await customFetch<{contents?: ApiContent[]}>({url: '/v1/admin/local-content', method: 'GET'}); setContents((res.contents ?? []).map(toContent)); } catch { toast.error('지역 콘텐츠를 불러오지 못했습니다. 개발자 계정인지 확인해주세요.'); } finally { setLoading(false); } }; useEffect(() => { void load(); }, []); const publishSelected = async () => { const ids = contents.filter((item) => item.selected).map((item) => item.id); try { await customFetch({url: '/v1/admin/local-content/publish', method: 'POST', data: {content_ids: ids}}); toast.success(`${ids.length}건을 발행했습니다.`); await load(); } catch { toast.error('발행하지 못했습니다.'); } }; const sync = async () => { const regionCode = window.prompt('내부 지역 코드(예: gunsan)를 입력하세요.', 'gunsan')?.trim(); if (!regionCode) return; setSyncing(true); try { const res = await customFetch<{result?: {success?: boolean}; msg?: string; collected?: number; skipped?: number}>({ url: '/v1/admin/local-content/sync-festivals', method: 'POST', data: {region_code: regionCode}, }); if (res.result?.success === false) throw new Error(res.msg); toast.success(`${res.collected ?? 0}건 수집 · ${res.skipped ?? 0}건 중복 제외`); await load(); } catch (error) { toast.error(error instanceof Error ? error.message : '공공데이터 수집에 실패했습니다.'); } finally { setSyncing(false); } }; const edit = async (item: LocalContent) => { const title = window.prompt('노출 제목을 입력하세요.', item.title)?.trim(); if (!title) return; const start = window.prompt('노출 시작 시각(예: 2026-09-01T09:00, 비우면 미설정)', item.displayStart?.slice(0, 16) ?? '')?.trim(); const end = window.prompt('노출 종료 시각(예: 2026-09-07T22:00, 비우면 미설정)', item.displayEnd?.slice(0, 16) ?? '')?.trim(); try { await customFetch({ url: `/v1/admin/local-content/${item.id}`, method: 'PATCH', data: {title, display_start_at: start ? new Date(start).toISOString() : null, display_end_at: end ? new Date(end).toISOString() : null}, }); toast.success('콘텐츠와 노출 일정을 저장했습니다.'); await load(); } catch { toast.error('콘텐츠를 수정하지 못했습니다.'); } }; const endContent = async (item: LocalContent) => { if (!window.confirm(`'${item.title}' 발행을 종료할까요?`)) return; try { await customFetch({url: `/v1/admin/local-content/${item.id}/end`, method: 'POST'}); toast.success('발행을 종료했습니다.'); await load(); } catch { toast.error('발행을 종료하지 못했습니다.'); } }; return ( {syncing ? '수집 중…' : '공공데이터 수집'}} >
item.status === 1).length} /> item.status === 2).length} />
setQuery(event.target.value)} placeholder="축제명 또는 지역 검색" className="pl-9" />
{loading &&

콘텐츠를 불러오는 중…

} {!loading && !filtered.length &&

수집된 지역 콘텐츠가 없습니다.

} {filtered.map((item) => ( toggle(item.id)} className="size-4 accent-primary" aria-label={`${item.title} 선택`} /> {item.imageUrl && }

{item.title}

{STATUS_LABEL[item.status]}
{item.region} {item.period}

출처: {item.source} · 외부 ID {item.id}

{item.status === 2 && }
))}
); } function Summary({label, value}: {label: string; value: number}) { return

{label}

{value}

; }