From d6ace70bff6972d6453dfd9398ad7f05a6a0815c Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 25 Apr 2026 01:17:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(travel):=20=EC=82=AC=EC=A7=84=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=E2=80=94=20POST=20/api/travel/sync=20=ED=98=B8=EC=B6=9C=20+=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/pages/travel/Travel.css | 58 +++++++++++++++++++++++++++++++++++++ src/pages/travel/Travel.jsx | 47 +++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/pages/travel/Travel.css b/src/pages/travel/Travel.css index 40600b2..0c7ce1f 100644 --- a/src/pages/travel/Travel.css +++ b/src/pages/travel/Travel.css @@ -92,6 +92,64 @@ max-width: 420px; } +/* Sync button */ +.tv-sync-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 14px; + padding: 6px 14px; + background: var(--tv-surface-2); + border: 1px solid var(--tv-line); + border-radius: var(--tv-r-sm); + color: var(--tv-muted); + font-family: var(--tv-mono); + font-size: 11px; + cursor: pointer; + transition: color 0.2s, border-color 0.2s; +} + +.tv-sync-btn:hover:not(:disabled) { + color: var(--tv-text); + border-color: var(--tv-line-bright); +} + +.tv-sync-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tv-sync-btn.is-syncing svg { + animation: tv-spin 1s linear infinite; +} + +@keyframes tv-spin { + to { transform: rotate(360deg); } +} + +.tv-sync-toast { + margin-top: 8px; + padding: 6px 12px; + background: rgba(200, 144, 94, 0.12); + border: 1px solid rgba(200, 144, 94, 0.25); + border-radius: var(--tv-r-sm); + font-family: var(--tv-mono); + font-size: 11px; + color: var(--tv-accent); + animation: tv-fade-in 0.3s ease; +} + +.tv-sync-toast.is-error { + background: rgba(220, 80, 80, 0.12); + border-color: rgba(220, 80, 80, 0.25); + color: #dc5050; +} + +@keyframes tv-fade-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + /* Active region info */ .tv-header__active-region { display: flex; diff --git a/src/pages/travel/Travel.jsx b/src/pages/travel/Travel.jsx index 47c507f..2b2eda2 100644 --- a/src/pages/travel/Travel.jsx +++ b/src/pages/travel/Travel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import useTravelData from './useTravelData'; import MiniMap, { getRegionAccent } from './MiniMap'; import AlbumCard from './AlbumCard'; @@ -30,6 +30,9 @@ const Travel = () => { /* ── Local state ──────────────────────────── */ const [selectedAlbum, setSelectedAlbum] = useState(null); const [albumSourceRect, setAlbumSourceRect] = useState(null); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + const syncTimerRef = useRef(null); /* ── Computed ──────────────────────────────── */ const regionAccent = getRegionAccent(selectedRegion || ''); @@ -71,6 +74,26 @@ const Travel = () => { return reloadAlbumPhotos(selectedAlbum.region, selectedAlbum.name); }, [selectedAlbum, reloadAlbumPhotos]); + const handleSync = useCallback(async () => { + if (syncing) return; + setSyncing(true); + setSyncResult(null); + try { + const res = await fetch('/api/travel/sync', { method: 'POST' }); + if (!res.ok) throw new Error(`${res.status}`); + const data = await res.json(); + setSyncResult(data); + if (syncTimerRef.current) clearTimeout(syncTimerRef.current); + syncTimerRef.current = setTimeout(() => setSyncResult(null), 8000); + } catch (e) { + setSyncResult({ error: e.message }); + if (syncTimerRef.current) clearTimeout(syncTimerRef.current); + syncTimerRef.current = setTimeout(() => setSyncResult(null), 5000); + } finally { + setSyncing(false); + } + }, [syncing]); + /* ── Render ────────────────────────────────── */ return (
@@ -92,6 +115,28 @@ const Travel = () => { 여행에서 포착한 색, 빛, 장면들을 필름처럼 현상합니다. 지도에서 지역을 선택하면 해당 앨범이 펼쳐집니다.

+ + {syncResult && ( +
+ {syncResult.error + ? `동기화 실패: ${syncResult.error}` + : `+${syncResult.added} 추가 / ${syncResult.removed} 삭제 / 썸네일 ${syncResult.thumbs_generated}개 (${syncResult.duration_sec}s)` + } +
+ )}
{selectedRegion ? (