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;
}
/* ── 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 {
width: 40px;
height: 40px;

View File

@@ -51,6 +51,8 @@ export default function HeroLightbox({
const pendingAdvanceRef = useRef(false);
const stripRef = useRef(null);
const prevOverflowRef = useRef('');
const [coverStatus, setCoverStatus] = useState(null); // 'saving' | 'done' | 'error'
const coverTimerRef = useRef(null);
const accent = useMemo(() => getRegionAccent(regionId), [regionId]);
const reduced = useMemo(() => prefersReduced(), []);
const animMs = reduced ? 0 : ANIM_MS;
@@ -146,6 +148,28 @@ export default function HeroLightbox({
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 — */
const photo = photos[selectedIndex];
if (!photo) return null;
@@ -183,6 +207,17 @@ export default function HeroLightbox({
{' / '}
{photos.length}
</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
className="hero-lb__close"
onClick={handleClose}

View File

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