feat: 메뉴 options JSON 필드 UI 구현

- 리스트: 섹션, 메타 컬럼 분리 표시
- 등록/수정: 확장 옵션 섹션 추가 (section, meta)
- 메타 데이터 팝오버로 상세 보기
This commit is contained in:
2025-12-16 16:27:13 +09:00
parent a9abab3a32
commit 3404b5d568
4 changed files with 259 additions and 1 deletions

View File

@@ -109,6 +109,43 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
placeholder="https://example.com">
</div>
<!-- 확장 옵션 섹션 -->
<div class="border-t border-gray-200 pt-4 mt-6 mb-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-700">확장 옵션</h3>
<button type="button" onclick="toggleOptionsSection()" class="text-xs text-blue-600 hover:text-blue-800">
<span id="options-toggle-text">펼치기</span>
</button>
</div>
<div id="options-section" class="hidden space-y-4">
<!-- section -->
<div>
<label for="option_section" class="block text-sm font-medium text-gray-700 mb-1">
section
</label>
<select name="option_section" id="option_section"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="main">main (기본)</option>
<option value="tools">tools (도구)</option>
<option value="labs">labs (실험실)</option>
</select>
<p class="text-xs text-gray-400 mt-1">사이드바 영역 구분. main: 메인 메뉴, tools: 개발도구 영역, labs: R&D 실험실 영역</p>
</div>
<!-- meta (JSON) -->
<div>
<label for="option_meta" class="block text-sm font-medium text-gray-700 mb-1">
meta <span class="text-xs text-gray-400">(JSON)</span>
</label>
<textarea name="option_meta" id="option_meta" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
placeholder='{"tab": "S", "badge": "new"}'></textarea>
<p class="text-xs text-gray-400 mt-1">추가 메타 데이터 (JSON 형식). 구분, 뱃지 표시 커스텀 속성 저장</p>
</div>
</div>
</div>
<!-- 버튼 그룹 -->
<div class="flex justify-end gap-4 mt-6">
<a href="{{ route('menus.index') }}"
@@ -137,6 +174,41 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
}
});
// 확장 옵션 섹션 토글
window.toggleOptionsSection = function() {
const section = document.getElementById('options-section');
const toggleText = document.getElementById('options-toggle-text');
if (section.classList.contains('hidden')) {
section.classList.remove('hidden');
toggleText.textContent = '접기';
} else {
section.classList.add('hidden');
toggleText.textContent = '펼치기';
}
};
// options 객체 생성
function buildOptionsObject() {
const options = {};
const section = document.getElementById('option_section').value;
if (section && section !== 'main') {
options.section = section;
}
const metaStr = document.getElementById('option_meta').value.trim();
if (metaStr) {
try {
options.meta = JSON.parse(metaStr);
} catch (e) {
showToast('meta 필드의 JSON 형식이 올바르지 않습니다.', 'error');
throw e;
}
}
return Object.keys(options).length > 0 ? options : null;
}
// 폼 제출 처리
document.getElementById('menuForm').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -144,6 +216,20 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
// option_ 접두사 필드 제거
delete data.option_section;
delete data.option_meta;
// options 객체 추가
try {
const options = buildOptionsObject();
if (options) {
data.options = options;
}
} catch (e) {
return; // JSON 파싱 에러
}
try {
const response = await fetch('/api/admin/menus', {
method: 'POST',

View File

@@ -122,6 +122,54 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
placeholder="https://example.com">
</div>
<!-- 확장 옵션 섹션 -->
@php
$hasOptions = $menu->options && count($menu->options) > 0;
$currentSection = $menu->getSection() ?? 'main';
$currentMeta = $menu->getMeta();
$currentMetaJson = $currentMeta ? json_encode($currentMeta, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '';
@endphp
<div class="border-t border-gray-200 pt-4 mt-6 mb-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-700">
확장 옵션
@if($hasOptions)
<span class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">{{ count($menu->options) }}</span>
@endif
</h3>
<button type="button" onclick="toggleOptionsSection()" class="text-xs text-blue-600 hover:text-blue-800">
<span id="options-toggle-text">{{ $hasOptions ? '접기' : '펼치기' }}</span>
</button>
</div>
<div id="options-section" class="{{ $hasOptions ? '' : 'hidden' }} space-y-4">
<!-- section -->
<div>
<label for="option_section" class="block text-sm font-medium text-gray-700 mb-1">
section
</label>
<select name="option_section" id="option_section"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="main" {{ $currentSection === 'main' ? 'selected' : '' }}>main (기본)</option>
<option value="tools" {{ $currentSection === 'tools' ? 'selected' : '' }}>tools (도구)</option>
<option value="labs" {{ $currentSection === 'labs' ? 'selected' : '' }}>labs (실험실)</option>
</select>
<p class="text-xs text-gray-400 mt-1">사이드바 영역 구분. main: 메인 메뉴, tools: 개발도구 영역, labs: R&D 실험실 영역</p>
</div>
<!-- meta (JSON) -->
<div>
<label for="option_meta" class="block text-sm font-medium text-gray-700 mb-1">
meta <span class="text-xs text-gray-400">(JSON)</span>
</label>
<textarea name="option_meta" id="option_meta" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
placeholder='{"tab": "S", "badge": "new"}'>{{ $currentMetaJson }}</textarea>
<p class="text-xs text-gray-400 mt-1">추가 메타 데이터 (JSON 형식). 구분, 뱃지 표시 커스텀 속성 저장</p>
</div>
</div>
</div>
<!-- 버튼 그룹 -->
<div class="flex justify-end gap-4 mt-6">
<a href="{{ route('menus.index') }}"
@@ -150,6 +198,41 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
}
});
// 확장 옵션 섹션 토글
window.toggleOptionsSection = function() {
const section = document.getElementById('options-section');
const toggleText = document.getElementById('options-toggle-text');
if (section.classList.contains('hidden')) {
section.classList.remove('hidden');
toggleText.textContent = '접기';
} else {
section.classList.add('hidden');
toggleText.textContent = '펼치기';
}
};
// options 객체 생성
function buildOptionsObject() {
const options = {};
const section = document.getElementById('option_section').value;
if (section && section !== 'main') {
options.section = section;
}
const metaStr = document.getElementById('option_meta').value.trim();
if (metaStr) {
try {
options.meta = JSON.parse(metaStr);
} catch (e) {
showToast('meta 필드의 JSON 형식이 올바르지 않습니다.', 'error');
throw e;
}
}
return Object.keys(options).length > 0 ? options : null;
}
// 폼 제출 처리
document.getElementById('menuForm').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -157,6 +240,20 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
// option_ 접두사 필드 제거
delete data.option_section;
delete data.option_meta;
// options 객체 추가
try {
const options = buildOptionsObject();
if (options) {
data.options = options;
}
} catch (e) {
return; // JSON 파싱 에러
}
try {
const response = await fetch('/api/admin/menus/{{ $menu->id }}', {
method: 'PUT',

View File

@@ -792,6 +792,31 @@ function saveMenuOrder(items) {
}, { title: '메뉴 가져오기', icon: 'question' });
};
// 옵션 팝오버 토글
window.toggleOptionsPopover = function(menuId) {
const popover = document.getElementById(`options-popover-${menuId}`);
if (!popover) return;
// 다른 팝오버 모두 닫기
document.querySelectorAll('.options-popover').forEach(p => {
if (p.id !== `options-popover-${menuId}`) {
p.classList.add('hidden');
}
});
// 현재 팝오버 토글
popover.classList.toggle('hidden');
};
// 외부 클릭 시 팝오버 닫기
document.addEventListener('click', function(e) {
if (!e.target.closest('.options-popover') && !e.target.closest('[onclick*="toggleOptionsPopover"]')) {
document.querySelectorAll('.options-popover').forEach(p => {
p.classList.add('hidden');
});
}
});
</script>
<script src="{{ asset('js/menu-tree.js') }}"></script>
@endpush

View File

@@ -20,6 +20,8 @@ class="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">활성</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">숨김</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">구분</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">섹션</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">메타</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider whitespace-nowrap">작업</th>
</tr>
</thead>
@@ -155,6 +157,54 @@ class="relative inline-flex h-4 w-7 items-center rounded-full transition-colors
<span class="text-gray-300">-</span>
@endif
</td>
{{-- 섹션 --}}
<td class="px-3 py-2 whitespace-nowrap text-center">
@php
$section = $menu->getSection();
$sectionColors = [
'main' => 'bg-gray-100 text-gray-600',
'tools' => 'bg-orange-100 text-orange-700',
'labs' => 'bg-purple-100 text-purple-700',
];
$sectionLabels = [
'main' => 'main',
'tools' => 'tools',
'labs' => 'labs',
];
@endphp
@if($section && $section !== 'main')
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ $sectionColors[$section] ?? 'bg-gray-100 text-gray-600' }}">
{{ $sectionLabels[$section] ?? $section }}
</span>
@else
<span class="text-gray-300">-</span>
@endif
</td>
{{-- 메타 --}}
<td class="px-3 py-2 whitespace-nowrap text-center">
@php $meta = $menu->getMeta(); @endphp
@if(!empty($meta))
<div class="relative inline-block">
<button type="button"
onclick="toggleOptionsPopover({{ $menu->id }})"
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded bg-gray-100 text-gray-700 hover:bg-gray-200 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
보기
</button>
{{-- 팝오버 --}}
<div id="options-popover-{{ $menu->id }}"
class="options-popover hidden absolute z-50 right-0 mt-1 w-72 bg-white rounded-lg shadow-lg border border-gray-200 p-3 text-left">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 pb-1 border-b">메타 데이터</div>
<div class="font-mono text-xs bg-gray-50 rounded p-2 text-gray-700 break-all whitespace-pre-wrap">{{ json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) }}</div>
<p class="text-xs text-gray-400 mt-2">추가 설정 데이터 ( 구분, 커스텀 속성 )</p>
</div>
</div>
@else
<span class="text-gray-300">-</span>
@endif
</td>
{{-- 작업 --}}
<td class="px-3 py-2 whitespace-nowrap text-center">
@if($importMode ?? false)
@@ -188,7 +238,7 @@ class="inline-flex items-center px-2 py-1 text-xs font-medium rounded bg-red-100
</tr>
@empty
<tr>
<td colspan="9" class="px-4 py-3 text-center text-gray-500">
<td colspan="11" class="px-4 py-3 text-center text-gray-500">
메뉴가 없습니다.
</td>
</tr>