feat(travel): 사진 그리드 안정화 + 앨범 커버 지정 버튼 + 동기화 결과 개선

- MasonryGrid: CSS columns → CSS Grid로 전환 (스크롤 시 정렬 위치 변동 방지)
- HeroLightbox: "커버로 지정" 버튼 추가 (PUT /api/travel/albums/{album}/cover 호출)
- Travel: 동기화 토스트에 신규 폴더 발견 수 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:13:14 +09:00
parent 047e15cad3
commit 093ca6635a
4 changed files with 83 additions and 12 deletions

View File

@@ -59,6 +59,41 @@
font-weight: 600; font-weight: 600;
} }
/* ── Cover button ── */
.hero-lb__cover-btn {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.08);
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
font-family: var(--tv-mono, 'Space Mono', 'Courier New', monospace);
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.hero-lb__cover-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.16);
color: var(--tv-text, #e8ddd0);
border-color: rgba(255, 255, 255, 0.3);
}
.hero-lb__cover-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hero-lb__cover-btn--done {
border-color: rgba(200, 144, 94, 0.5);
color: #c8905e;
background: rgba(200, 144, 94, 0.12);
}
.hero-lb__cover-btn--error {
border-color: rgba(220, 80, 80, 0.5);
color: #dc5050;
background: rgba(220, 80, 80, 0.12);
}
.hero-lb__close { .hero-lb__close {
width: 40px; width: 40px;
height: 40px; height: 40px;

View File

@@ -51,6 +51,8 @@ export default function HeroLightbox({
const pendingAdvanceRef = useRef(false); const pendingAdvanceRef = useRef(false);
const stripRef = useRef(null); const stripRef = useRef(null);
const prevOverflowRef = useRef(''); const prevOverflowRef = useRef('');
const [coverStatus, setCoverStatus] = useState(null); // 'saving' | 'done' | 'error'
const coverTimerRef = useRef(null);
const accent = useMemo(() => getRegionAccent(regionId), [regionId]); const accent = useMemo(() => getRegionAccent(regionId), [regionId]);
const reduced = useMemo(() => prefersReduced(), []); const reduced = useMemo(() => prefersReduced(), []);
const animMs = reduced ? 0 : ANIM_MS; const animMs = reduced ? 0 : ANIM_MS;
@@ -146,6 +148,28 @@ export default function HeroLightbox({
delta: 30, delta: 30,
}); });
/* — Set as album cover — */
const handleSetCover = useCallback(async () => {
const p = photos[selectedIndex];
if (!p || !albumName || coverStatus === 'saving') return;
const filename = p.file || p.filename || p.name || '';
if (!filename) return;
setCoverStatus('saving');
try {
const res = await fetch(`/api/travel/albums/${encodeURIComponent(albumName)}/cover`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
});
if (!res.ok) throw new Error(`${res.status}`);
setCoverStatus('done');
} catch {
setCoverStatus('error');
}
if (coverTimerRef.current) clearTimeout(coverTimerRef.current);
coverTimerRef.current = setTimeout(() => setCoverStatus(null), 2000);
}, [selectedIndex, photos, albumName, coverStatus]);
/* — Current photo — */ /* — Current photo — */
const photo = photos[selectedIndex]; const photo = photos[selectedIndex];
if (!photo) return null; if (!photo) return null;
@@ -183,6 +207,17 @@ export default function HeroLightbox({
{' / '} {' / '}
{photos.length} {photos.length}
</span> </span>
<button
className={`hero-lb__cover-btn${coverStatus === 'done' ? ' hero-lb__cover-btn--done' : coverStatus === 'error' ? ' hero-lb__cover-btn--error' : ''}`}
onClick={handleSetCover}
disabled={coverStatus === 'saving'}
title="이 사진을 앨범 커버로 지정"
>
{coverStatus === 'saving' ? '저장 중…'
: coverStatus === 'done' ? '커버 지정됨'
: coverStatus === 'error' ? '실패'
: '커버로 지정'}
</button>
<button <button
className="hero-lb__close" className="hero-lb__close"
onClick={handleClose} onClick={handleClose}

View File

@@ -1,18 +1,18 @@
/* ── MasonryGrid ── */ /* ── MasonryGrid — stable CSS Grid layout ── */
.masonry-grid { .masonry-grid {
column-count: 4; display: grid;
column-gap: 8px; grid-template-columns: repeat(4, 1fr);
gap: 8px;
} }
/* item */ /* item */
.masonry-item { .masonry-item {
break-inside: avoid;
margin-bottom: 8px;
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
cursor: zoom-in; cursor: zoom-in;
aspect-ratio: 4 / 3;
/* scroll-reveal initial state */ /* scroll-reveal initial state */
opacity: 0; opacity: 0;
@@ -28,7 +28,8 @@
.masonry-item__img { .masonry-item__img {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: 100%;
object-fit: cover;
transition: filter 0.25s ease; transition: filter 0.25s ease;
} }
@@ -62,12 +63,12 @@
/* sentinel */ /* sentinel */
.masonry-sentinel { .masonry-sentinel {
height: 1px; height: 1px;
column-span: all; grid-column: 1 / -1;
} }
/* loading dots */ /* loading dots */
.masonry-loading { .masonry-loading {
column-span: all; grid-column: 1 / -1;
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
@@ -97,7 +98,7 @@
/* end message */ /* end message */
.masonry-end { .masonry-end {
column-span: all; grid-column: 1 / -1;
text-align: center; text-align: center;
font: 11px var(--tv-mono); font: 11px var(--tv-mono);
letter-spacing: 0.12em; letter-spacing: 0.12em;
@@ -109,13 +110,13 @@
/* responsive */ /* responsive */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.masonry-grid { .masonry-grid {
column-count: 3; grid-template-columns: repeat(3, 1fr);
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.masonry-grid { .masonry-grid {
column-count: 2; grid-template-columns: repeat(2, 1fr);
} }
} }

View File

@@ -133,7 +133,7 @@ const Travel = () => {
<div className={`tv-sync-toast${syncResult.error ? ' is-error' : ''}`}> <div className={`tv-sync-toast${syncResult.error ? ' is-error' : ''}`}>
{syncResult.error {syncResult.error
? `동기화 실패: ${syncResult.error}` ? `동기화 실패: ${syncResult.error}`
: `+${syncResult.added} 추가 / ${syncResult.removed} 삭제 / 썸네일 ${syncResult.thumbs_generated}개 (${syncResult.duration_sec}s)` : `+${syncResult.added} 추가 / ${syncResult.removed} 삭제 / 썸네일 ${syncResult.thumbs_generated}${syncResult.discovered ? ` / 신규 폴더 ${syncResult.discovered}개 발견` : ''} (${syncResult.duration_sec}s)`
} }
</div> </div>
)} )}