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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
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 PullToRefresh from '../../components/PullToRefresh';
|
||||
import MasonryGrid from './MasonryGrid';
|
||||
@@ -30,6 +30,7 @@ export default function AlbumDetail({
|
||||
onLoadMore,
|
||||
onReload,
|
||||
onCoverChange,
|
||||
regions,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -39,6 +40,79 @@ export default function AlbumDetail({
|
||||
const [lightboxRect, setLightboxRect] = useState(null);
|
||||
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
|
||||
useEffect(() => {
|
||||
if (prefersReduced()) {
|
||||
@@ -62,13 +136,13 @@ export default function AlbumDetail({
|
||||
/* ── ESC key (close album when lightbox not open) ── */
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape' && selectedPhotoIndex == null) {
|
||||
if (e.key === 'Escape' && selectedPhotoIndex == null && !editingRegion) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('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 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -183,8 +257,57 @@ export default function AlbumDetail({
|
||||
|
||||
<div className="album-detail__title-group">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -222,6 +222,7 @@ const Travel = () => {
|
||||
onLoadMore={handleLoadMore}
|
||||
onReload={handleReload}
|
||||
onCoverChange={refreshAlbums}
|
||||
regions={regions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user