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:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user