관리자 미디어 필터 입력 공통화
This commit is contained in:
58
components/admin/AdminFilterPillGroup.vue
Normal file
58
components/admin/AdminFilterPillGroup.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 현재 선택 값 */
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean],
|
||||
default: ''
|
||||
},
|
||||
/** 필터 옵션 목록 */
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
/** 필터 그룹 접근성 라벨 */
|
||||
label: {
|
||||
type: String,
|
||||
default: '필터'
|
||||
},
|
||||
/** 그룹 루트에 추가할 화면별 클래스 */
|
||||
rootClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 버튼에 추가할 화면별 클래스 */
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/**
|
||||
* 필터 옵션이 선택 상태인지 확인한다.
|
||||
* @param {Object} option - 필터 옵션
|
||||
* @returns {boolean} 선택 상태
|
||||
*/
|
||||
const isSelected = (option) => option.value === props.modelValue
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-filter-pill-group flex flex-wrap gap-1.5" :class="rootClass" role="group" :aria-label="label">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="String(option.value)"
|
||||
class="admin-filter-pill-group__button rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="[
|
||||
isSelected(option) ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink',
|
||||
buttonClass
|
||||
]"
|
||||
type="button"
|
||||
:aria-pressed="isSelected(option)"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span v-if="option.count !== undefined" class="ml-1 opacity-70">{{ option.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
const props = defineProps({
|
||||
/** 선택 입력 값 */
|
||||
modelValue: {
|
||||
type: String,
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
/** 선택 옵션 목록 */
|
||||
@@ -34,13 +34,22 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/**
|
||||
* option에 전달할 실제 값을 반환한다.
|
||||
* @param {Object} option - 선택 옵션
|
||||
* @returns {string|number} 선택 값
|
||||
*/
|
||||
const getOptionValue = (option) => option.value
|
||||
|
||||
/**
|
||||
* 선택 값을 상위 컴포넌트에 전달한다.
|
||||
* @param {Event} event - 변경 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateValue = (event) => {
|
||||
emit('update:modelValue', event.target?.value || '')
|
||||
const selectedValue = event.target?.value || ''
|
||||
const selectedOption = props.options.find((option) => String(option.value) === selectedValue)
|
||||
emit('update:modelValue', selectedOption ? selectedOption.value : selectedValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,7 +64,7 @@ const updateValue = (event) => {
|
||||
:aria-label="label"
|
||||
@change="updateValue"
|
||||
>
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||
<option v-for="option in options" :key="option.value" :value="getOptionValue(option)">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
@@ -189,7 +189,8 @@
|
||||
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
|
||||
| components/admin/AdminToast.vue | 관리자 공통 우측 상단 토스트 표시(성공·오류·안내 톤, 화면별 상단 위치 조정) |
|
||||
| components/admin/AdminSearchInput.vue | 관리자 공통 검색 입력(돋보기 아이콘, IME 검색 이벤트 전달, 외부 focus/select 노출) |
|
||||
| components/admin/AdminSelectInput.vue | 관리자 공통 select 입력(기본 필터·정렬 옵션, chevron 아이콘, `v-model` 전달) |
|
||||
| components/admin/AdminSelectInput.vue | 관리자 공통 select 입력(문자·숫자 옵션 값, 기본 필터·정렬 옵션, chevron 아이콘, `v-model` 전달) |
|
||||
| components/admin/AdminFilterPillGroup.vue | 관리자 공통 pill 필터 그룹(선택 상태·개수 배지·aria-pressed) |
|
||||
| components/admin/AdminPagination.vue | 관리자 공통 번호형 페이지네이션(이전·다음 화살표, 첫/끝/현재 주변 페이지, ellipsis 접기) |
|
||||
| components/admin/AdminAnalyticsTrendChart.vue | 관리자 통계 추이 막대 차트(축 라벨·증감 툴팁·기간 페이지 이동) |
|
||||
| lib/analytics-chart-ui.js | 통계 차트 라벨·페이지네이션·증감 계산 유틸 |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||
- 관리자 UI 공통 컴포넌트 정리.
|
||||
- 모달 패널, 필터 팝오버의 radius·shadow·z-index·색상 규칙을 공통화.
|
||||
- 게시글/페이지/미디어 목록의 고급 필터 레이아웃을 같은 패턴으로 유지.
|
||||
- 멤버 고급 필터와 설정 화면 select 입력의 공통 컴포넌트 적용 범위 검토.
|
||||
- 최근 브라우저 회귀 테스트 추가.
|
||||
- 태그 추천 선택 후 다음 태그 후보가 포커스 해제 없이 유지되는지 확인.
|
||||
- 관리자 미디어 대표 이미지/본문 이미지 탭 전환 중 역할 배지가 노출되지 않는지 확인.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.9
|
||||
|
||||
- 관리자 공용 select 입력이 숫자 옵션 값을 보존하도록 보완.
|
||||
- 관리자 미디어 표시 개수 select를 공용 `AdminSelectInput`으로 교체.
|
||||
- 관리자 미디어 종류·사용 상태 필터를 공용 `AdminFilterPillGroup` 컴포넌트로 분리.
|
||||
|
||||
## v1.11.8
|
||||
|
||||
- 관리자 목록 select 입력을 공용 `AdminSelectInput` 컴포넌트로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.8",
|
||||
"version": "1.11.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.8",
|
||||
"version": "1.11.9",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.8",
|
||||
"version": "1.11.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -23,7 +23,10 @@ definePageMeta({
|
||||
const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
||||
|
||||
/** 관리자 미디어 한 페이지 표시 개수 옵션 */
|
||||
const mediaPageSizeOptions = [50, 100, 200]
|
||||
const mediaPageSizeOptions = [50, 100, 200].map((size) => ({
|
||||
value: size,
|
||||
label: `${size}개`
|
||||
}))
|
||||
|
||||
/** 회원 등급 코드 → 미디어 배지 라벨 */
|
||||
const memberRoleBadgeLabels = {
|
||||
@@ -489,11 +492,11 @@ const mediaKindFilterOptions = computed(() => {
|
||||
const countByKind = mediaCounts.value.kinds || {}
|
||||
|
||||
return [
|
||||
{ id: 'all', label: '전체', count: countByKind.all || 0 },
|
||||
{ id: 'image', label: '이미지', count: countByKind.image || 0 },
|
||||
{ id: 'video', label: '영상', count: countByKind.video || 0 },
|
||||
{ id: 'audio', label: '음악', count: countByKind.audio || 0 },
|
||||
{ id: 'file', label: '파일', count: countByKind.file || 0 }
|
||||
{ value: 'all', label: '전체', count: countByKind.all || 0 },
|
||||
{ value: 'image', label: '이미지', count: countByKind.image || 0 },
|
||||
{ value: 'video', label: '영상', count: countByKind.video || 0 },
|
||||
{ value: 'audio', label: '음악', count: countByKind.audio || 0 },
|
||||
{ value: 'file', label: '파일', count: countByKind.file || 0 }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -501,8 +504,8 @@ const mediaUsageFilterOptions = computed(() => {
|
||||
const usageCounts = mediaCounts.value.usage || {}
|
||||
|
||||
return [
|
||||
{ id: 'all', label: '전체', count: usageCounts.all || 0, unusedOnly: false },
|
||||
{ id: 'unused', label: '미사용', count: usageCounts.unused || 0, unusedOnly: true }
|
||||
{ value: false, id: 'all', label: '전체', count: usageCounts.all || 0 },
|
||||
{ value: true, id: 'unused', label: '미사용', count: usageCounts.unused || 0 }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -676,11 +679,11 @@ const setMediaPage = (page) => {
|
||||
|
||||
/**
|
||||
* 관리자 미디어 페이지 표시 개수를 변경한다.
|
||||
* @param {Event} event - select 변경 이벤트
|
||||
* @param {number} pageSize - 변경할 표시 개수
|
||||
* @returns {void}
|
||||
*/
|
||||
const setMediaPageSize = (event) => {
|
||||
mediaPageSize.value = Number(event.target.value) || 50
|
||||
const setMediaPageSize = (pageSize) => {
|
||||
mediaPageSize.value = Number(pageSize) || 50
|
||||
mediaPage.value = 1
|
||||
clearMediaSelection()
|
||||
closeMediaDetail()
|
||||
@@ -1411,31 +1414,21 @@ watch(() => mediaPagination.value.page, (page) => {
|
||||
</div>
|
||||
|
||||
<div class="admin-media__filters flex flex-wrap gap-1.5">
|
||||
<template v-if="activeTab === 'library'">
|
||||
<button
|
||||
v-for="option in mediaKindFilterOptions"
|
||||
:key="option.id"
|
||||
class="admin-media__kind-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="activeMediaKind === option.id ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
|
||||
type="button"
|
||||
@click="setMediaKindFilter(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="ml-1 opacity-70">{{ option.count }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-for="option in visibleMediaUsageFilterOptions"
|
||||
:key="`usage-${option.id}`"
|
||||
class="admin-media__usage-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="showUnusedOnly === option.unusedOnly ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
|
||||
type="button"
|
||||
:aria-pressed="showUnusedOnly === option.unusedOnly"
|
||||
@click="setMediaUsageFilter(option.unusedOnly)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="ml-1 opacity-70">{{ option.count }}</span>
|
||||
</button>
|
||||
<AdminFilterPillGroup
|
||||
v-if="activeTab === 'library'"
|
||||
:model-value="activeMediaKind"
|
||||
:options="mediaKindFilterOptions"
|
||||
label="미디어 종류 필터"
|
||||
button-class="admin-media__kind-filter"
|
||||
@update:model-value="setMediaKindFilter"
|
||||
/>
|
||||
<AdminFilterPillGroup
|
||||
:model-value="showUnusedOnly"
|
||||
:options="visibleMediaUsageFilterOptions"
|
||||
label="미디어 사용 상태 필터"
|
||||
button-class="admin-media__usage-filter"
|
||||
@update:model-value="setMediaUsageFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1536,24 +1529,17 @@ watch(() => mediaPagination.value.page, (page) => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-media__selection-actions flex flex-wrap items-center justify-end gap-2">
|
||||
<label v-if="filteredMediaItems.length" class="admin-media__page-size flex items-center gap-2 text-xs font-semibold text-muted">
|
||||
<div v-if="filteredMediaItems.length" class="admin-media__page-size flex items-center gap-2 text-xs font-semibold text-muted">
|
||||
<span>표시</span>
|
||||
<span class="admin-media__page-size-select-wrap relative inline-flex">
|
||||
<select
|
||||
class="admin-media__page-size-select appearance-none rounded border border-line bg-white py-2 pl-2 pr-8 text-xs font-semibold text-ink outline-none transition focus:border-[#8e9cac]"
|
||||
:value="mediaPageSize"
|
||||
@change="setMediaPageSize"
|
||||
>
|
||||
<option v-for="size in mediaPageSizeOptions" :key="size" :value="size">
|
||||
{{ size }}개
|
||||
</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</span>
|
||||
</label>
|
||||
<AdminSelectInput
|
||||
:model-value="mediaPageSize"
|
||||
:options="mediaPageSizeOptions"
|
||||
label="미디어 표시 개수"
|
||||
root-class="admin-media__page-size-select-wrap inline-flex"
|
||||
select-class="admin-media__page-size-select py-2 pl-2 pr-8 text-xs font-semibold text-ink"
|
||||
@update:model-value="setMediaPageSize"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="filteredMediaItems.length"
|
||||
class="admin-media__select-all rounded border border-line bg-white px-3 py-2 text-xs font-semibold text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
||||
Reference in New Issue
Block a user