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:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ const Travel = () => {
|
|||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
onReload={handleReload}
|
onReload={handleReload}
|
||||||
onCoverChange={refreshAlbums}
|
onCoverChange={refreshAlbums}
|
||||||
|
regions={regions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user