feat(travel): 앨범 지역 편집 UI — 텍스트 입력 + 자동완성

- AlbumDetail 헤더의 지역 라벨 클릭 → 인라인 편집 모드
- 기존 지역 목록 자동완성 제안 + 새 지역명 직접 입력 가능
- Enter/클릭으로 저장, Esc/✕로 취소
- PUT /api/travel/albums/{album}/region 호출 → 앨범 목록 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:52:10 +09:00
parent 59bb05ba22
commit 9b8daeffa4
3 changed files with 234 additions and 5 deletions

View File

@@ -85,6 +85,111 @@
width: fit-content; width: fit-content;
} }
.album-detail__region--editable {
border: 1px solid transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: border-color 0.2s, background 0.2s;
}
.album-detail__region--editable:hover {
border-color: var(--tv-line-bright, rgba(232, 221, 208, 0.22));
background: var(--tv-surface-2, #221c14);
}
.album-detail__region-edit-icon {
opacity: 0;
transition: opacity 0.2s;
}
.album-detail__region--editable:hover .album-detail__region-edit-icon {
opacity: 0.6;
}
.album-detail__region-ok {
color: #c8905e;
}
.album-detail__region-err {
color: #dc5050;
}
/* ── Region editor ── */
.album-detail__region-editor {
position: relative;
display: flex;
align-items: center;
gap: 4px;
}
.album-detail__region-input {
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
font-size: 11px;
letter-spacing: 0.04em;
color: var(--tv-text, #e8ddd0);
background: var(--tv-surface-2, #221c14);
border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
border-radius: 4px;
padding: 4px 8px;
width: 160px;
outline: none;
}
.album-detail__region-input:focus {
border-color: var(--tv-accent, #c8905e);
}
.album-detail__region-suggestions {
position: absolute;
top: 100%;
left: 0;
z-index: 10;
margin: 4px 0 0;
padding: 4px 0;
list-style: none;
background: var(--tv-surface, #1a1510);
border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22));
border-radius: 6px;
max-height: 180px;
overflow-y: auto;
min-width: 160px;
scrollbar-width: thin;
}
.album-detail__region-suggestion {
display: block;
width: 100%;
padding: 6px 12px;
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
font-size: 11px;
color: var(--tv-text, #e8ddd0);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.album-detail__region-suggestion:hover {
background: var(--tv-line, rgba(232, 221, 208, 0.1));
}
.album-detail__region-cancel {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: var(--tv-line, rgba(232, 221, 208, 0.1));
color: var(--tv-muted, rgba(232, 221, 208, 0.45));
font-size: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s;
}
.album-detail__region-cancel:hover {
background: var(--tv-line-bright, rgba(232, 221, 208, 0.22));
}
.album-detail__count { .album-detail__count {
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace); font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
font-size: 11px; font-size: 11px;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import SwipeableView from '../../components/SwipeableView'; import SwipeableView from '../../components/SwipeableView';
import PullToRefresh from '../../components/PullToRefresh'; import PullToRefresh from '../../components/PullToRefresh';
import MasonryGrid from './MasonryGrid'; import MasonryGrid from './MasonryGrid';
@@ -30,6 +30,7 @@ export default function AlbumDetail({
onLoadMore, onLoadMore,
onReload, onReload,
onCoverChange, onCoverChange,
regions,
}) { }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -39,6 +40,79 @@ export default function AlbumDetail({
const [lightboxRect, setLightboxRect] = useState(null); const [lightboxRect, setLightboxRect] = useState(null);
const closingRef = useRef(false); const closingRef = useRef(false);
/* ── Region editing ── */
const [editingRegion, setEditingRegion] = useState(false);
const [regionInput, setRegionInput] = useState('');
const [regionSaving, setRegionSaving] = useState(false);
const [regionMsg, setRegionMsg] = useState(null); // { type: 'ok'|'err', text }
const regionInputRef = useRef(null);
const regionMsgTimer = useRef(null);
const regionNames = useMemo(() => {
if (!regions?.features) return [];
return regions.features
.map((f) => f.properties?.name || f.properties?.id || '')
.filter(Boolean);
}, [regions]);
const filteredSuggestions = useMemo(() => {
if (!regionInput.trim()) return regionNames;
const q = regionInput.toLowerCase();
return regionNames.filter((n) => n.toLowerCase().includes(q));
}, [regionInput, regionNames]);
const handleRegionEditStart = useCallback(() => {
setEditingRegion(true);
setRegionInput(album?.regionName || '');
setRegionMsg(null);
setTimeout(() => regionInputRef.current?.focus(), 50);
}, [album]);
const handleRegionEditCancel = useCallback(() => {
setEditingRegion(false);
setRegionInput('');
setRegionMsg(null);
}, []);
const handleRegionSave = useCallback(async (value) => {
const name = (value ?? regionInput).trim();
if (!name || !album?.name) return;
if (name === album.regionName) {
setEditingRegion(false);
return;
}
setRegionSaving(true);
try {
const res = await fetch(
`/api/travel/albums/${encodeURIComponent(album.name)}/region`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region: name }),
},
);
if (!res.ok) throw new Error(`${res.status}`);
setRegionMsg({ type: 'ok', text: `${name}` });
setEditingRegion(false);
onCoverChange?.(); // refresh album list
} catch {
setRegionMsg({ type: 'err', text: '변경 실패' });
} finally {
setRegionSaving(false);
}
if (regionMsgTimer.current) clearTimeout(regionMsgTimer.current);
regionMsgTimer.current = setTimeout(() => setRegionMsg(null), 3000);
}, [regionInput, album, onCoverChange]);
const handleRegionKeyDown = useCallback((e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleRegionSave();
} else if (e.key === 'Escape') {
handleRegionEditCancel();
}
}, [handleRegionSave, handleRegionEditCancel]);
// Enter → open // Enter → open
useEffect(() => { useEffect(() => {
if (prefersReduced()) { if (prefersReduced()) {
@@ -62,13 +136,13 @@ export default function AlbumDetail({
/* ── ESC key (close album when lightbox not open) ── */ /* ── ESC key (close album when lightbox not open) ── */
useEffect(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
if (e.key === 'Escape' && selectedPhotoIndex == null) { if (e.key === 'Escape' && selectedPhotoIndex == null && !editingRegion) {
handleClose(); handleClose();
} }
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [selectedPhotoIndex]); // eslint-disable-line react-hooks/exhaustive-deps }, [selectedPhotoIndex, editingRegion]); // eslint-disable-line react-hooks/exhaustive-deps
/* ── Close with exit animation ── */ /* ── Close with exit animation ── */
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -183,8 +257,57 @@ export default function AlbumDetail({
<div className="album-detail__title-group"> <div className="album-detail__title-group">
<span className="album-detail__name">{album?.name || ''}</span> <span className="album-detail__name">{album?.name || ''}</span>
{album?.regionName && (
<span className="album-detail__region">{album.regionName}</span> {/* Region label / editor */}
{editingRegion ? (
<div className="album-detail__region-editor">
<input
ref={regionInputRef}
className="album-detail__region-input"
type="text"
value={regionInput}
onChange={(e) => setRegionInput(e.target.value)}
onKeyDown={handleRegionKeyDown}
placeholder="지역명 입력…"
disabled={regionSaving}
/>
{filteredSuggestions.length > 0 && (
<ul className="album-detail__region-suggestions">
{filteredSuggestions.map((name) => (
<li key={name}>
<button
className="album-detail__region-suggestion"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleRegionSave(name)}
>
{name}
</button>
</li>
))}
</ul>
)}
<button
className="album-detail__region-cancel"
onClick={handleRegionEditCancel}
aria-label="취소"
>
</button>
</div>
) : (
<button
className="album-detail__region album-detail__region--editable"
onClick={handleRegionEditStart}
title="클릭하여 지역 변경"
>
{regionMsg
? <span className={regionMsg.type === 'ok' ? 'album-detail__region-ok' : 'album-detail__region-err'}>{regionMsg.text}</span>
: (album?.regionName || '미분류')
}
<svg className="album-detail__region-edit-icon" width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M7.5 1.5l1 1-5.5 5.5H2V7z" stroke="currentColor" strokeWidth="0.8" strokeLinejoin="round"/>
</svg>
</button>
)} )}
</div> </div>

View File

@@ -222,6 +222,7 @@ const Travel = () => {
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
onReload={handleReload} onReload={handleReload}
onCoverChange={refreshAlbums} onCoverChange={refreshAlbums}
regions={regions}
/> />
)} )}
</div> </div>