feat(travel): 사진 동기화 버튼 추가 — POST /api/travel/sync 호출 + 결과 토스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,64 @@
|
|||||||
max-width: 420px;
|
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 */
|
/* Active region info */
|
||||||
.tv-header__active-region {
|
.tv-header__active-region {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import useTravelData from './useTravelData';
|
import useTravelData from './useTravelData';
|
||||||
import MiniMap, { getRegionAccent } from './MiniMap';
|
import MiniMap, { getRegionAccent } from './MiniMap';
|
||||||
import AlbumCard from './AlbumCard';
|
import AlbumCard from './AlbumCard';
|
||||||
@@ -30,6 +30,9 @@ const Travel = () => {
|
|||||||
/* ── Local state ──────────────────────────── */
|
/* ── Local state ──────────────────────────── */
|
||||||
const [selectedAlbum, setSelectedAlbum] = useState(null);
|
const [selectedAlbum, setSelectedAlbum] = useState(null);
|
||||||
const [albumSourceRect, setAlbumSourceRect] = useState(null);
|
const [albumSourceRect, setAlbumSourceRect] = useState(null);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncResult, setSyncResult] = useState(null);
|
||||||
|
const syncTimerRef = useRef(null);
|
||||||
|
|
||||||
/* ── Computed ──────────────────────────────── */
|
/* ── Computed ──────────────────────────────── */
|
||||||
const regionAccent = getRegionAccent(selectedRegion || '');
|
const regionAccent = getRegionAccent(selectedRegion || '');
|
||||||
@@ -71,6 +74,26 @@ const Travel = () => {
|
|||||||
return reloadAlbumPhotos(selectedAlbum.region, selectedAlbum.name);
|
return reloadAlbumPhotos(selectedAlbum.region, selectedAlbum.name);
|
||||||
}, [selectedAlbum, reloadAlbumPhotos]);
|
}, [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 ────────────────────────────────── */
|
/* ── Render ────────────────────────────────── */
|
||||||
return (
|
return (
|
||||||
<div className="travel" style={{ '--region-accent': regionAccent }}>
|
<div className="travel" style={{ '--region-accent': regionAccent }}>
|
||||||
@@ -92,6 +115,28 @@ const Travel = () => {
|
|||||||
여행에서 포착한 색, 빛, 장면들을 필름처럼 현상합니다.
|
여행에서 포착한 색, 빛, 장면들을 필름처럼 현상합니다.
|
||||||
지도에서 지역을 선택하면 해당 앨범이 펼쳐집니다.
|
지도에서 지역을 선택하면 해당 앨범이 펼쳐집니다.
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
className={`tv-sync-btn${syncing ? ' is-syncing' : ''}`}
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={syncing}
|
||||||
|
title="사진 폴더 동기화"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||||
|
<path d="M1.5 8a6.5 6.5 0 0 1 11.25-4.43" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
|
||||||
|
<path d="M14.5 8a6.5 6.5 0 0 1-11.25 4.43" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
|
||||||
|
<path d="M12 1.5v2.5h-2.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M4 14.5v-2.5h2.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{syncing ? '동기화 중…' : '동기화'}
|
||||||
|
</button>
|
||||||
|
{syncResult && (
|
||||||
|
<div className={`tv-sync-toast${syncResult.error ? ' is-error' : ''}`}>
|
||||||
|
{syncResult.error
|
||||||
|
? `동기화 실패: ${syncResult.error}`
|
||||||
|
: `+${syncResult.added} 추가 / ${syncResult.removed} 삭제 / 썸네일 ${syncResult.thumbs_generated}개 (${syncResult.duration_sec}s)`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedRegion ? (
|
{selectedRegion ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user