대표 이미지와 미디어 화면 개선
This commit is contained in:
@@ -18,6 +18,10 @@ const emit = defineEmits(['submit'])
|
|||||||
|
|
||||||
const slugTouched = ref(Boolean(props.initialPost.slug))
|
const slugTouched = ref(Boolean(props.initialPost.slug))
|
||||||
const blockEditor = ref(null)
|
const blockEditor = ref(null)
|
||||||
|
const mediaItems = ref([])
|
||||||
|
const isMediaPickerOpen = ref(false)
|
||||||
|
const isLoadingMedia = ref(false)
|
||||||
|
const isUploadingFeaturedImage = ref(false)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: props.initialPost.title || '',
|
title: props.initialPost.title || '',
|
||||||
@@ -67,6 +71,83 @@ const parseTags = (value) => [...new Set(value
|
|||||||
.map((tag) => toSlug(tag))
|
.map((tag) => toSlug(tag))
|
||||||
.filter(Boolean))]
|
.filter(Boolean))]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 라이브러리 목록 조회
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const fetchMediaItems = async () => {
|
||||||
|
isLoadingMedia.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
mediaItems.value = await $fetch('/admin/api/media')
|
||||||
|
} finally {
|
||||||
|
isLoadingMedia.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 선택 창 열기
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const openMediaPicker = async () => {
|
||||||
|
isMediaPickerOpen.value = true
|
||||||
|
await fetchMediaItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 선택 창 닫기
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeMediaPicker = () => {
|
||||||
|
isMediaPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 선택
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const selectFeaturedImage = (item) => {
|
||||||
|
form.featuredImage = item.url
|
||||||
|
closeMediaPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 삭제
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const removeFeaturedImage = () => {
|
||||||
|
form.featuredImage = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 파일 업로드
|
||||||
|
* @param {Event} event - 파일 입력 이벤트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const uploadFeaturedImage = async (event) => {
|
||||||
|
const files = event.target.files
|
||||||
|
|
||||||
|
if (!files?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('files', files[0])
|
||||||
|
isUploadingFeaturedImage.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await $fetch('/admin/api/uploads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
form.featuredImage = result.files?.[0]?.url || ''
|
||||||
|
} finally {
|
||||||
|
event.target.value = ''
|
||||||
|
isUploadingFeaturedImage.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 제목 입력 후 본문 에디터로 이동
|
* 제목 입력 후 본문 에디터로 이동
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -154,14 +235,38 @@ const submitPost = () => {
|
|||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="admin-post-form__field grid gap-2 text-sm">
|
<div class="admin-post-form__field grid gap-2 text-sm">
|
||||||
<span class="admin-post-form__label font-medium">대표 이미지 URL</span>
|
<span class="admin-post-form__label font-medium">대표 이미지</span>
|
||||||
<input
|
<figure v-if="form.featuredImage" class="admin-post-form__featured overflow-hidden rounded border border-line bg-white">
|
||||||
v-model="form.featuredImage"
|
<img class="admin-post-form__featured-image aspect-[4/3] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
|
||||||
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
|
<figcaption class="admin-post-form__featured-actions grid gap-2 p-3">
|
||||||
type="url"
|
<p class="admin-post-form__featured-url break-all text-xs text-muted">
|
||||||
>
|
{{ form.featuredImage }}
|
||||||
</label>
|
</p>
|
||||||
|
<div class="admin-post-form__featured-buttons flex flex-wrap gap-2">
|
||||||
|
<button class="admin-post-form__featured-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker">
|
||||||
|
변경
|
||||||
|
</button>
|
||||||
|
<label class="admin-post-form__featured-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
|
||||||
|
새 업로드
|
||||||
|
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||||
|
</label>
|
||||||
|
<button class="admin-post-form__featured-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeFeaturedImage">
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
<div v-else class="admin-post-form__featured-empty grid gap-2 rounded border border-dashed border-line bg-white p-4">
|
||||||
|
<button class="admin-post-form__featured-select rounded border border-line px-3 py-2 text-sm font-semibold" type="button" @click="openMediaPicker">
|
||||||
|
미디어에서 선택
|
||||||
|
</button>
|
||||||
|
<label class="admin-post-form__featured-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white">
|
||||||
|
{{ isUploadingFeaturedImage ? '업로드 중' : '새 이미지 업로드' }}
|
||||||
|
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,5 +282,44 @@ const submitPost = () => {
|
|||||||
{{ saving ? '저장 중' : submitLabel }}
|
{{ saving ? '저장 중' : submitLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isMediaPickerOpen"
|
||||||
|
class="admin-post-form__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="closeMediaPicker"
|
||||||
|
>
|
||||||
|
<section class="admin-post-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
|
||||||
|
<div class="admin-post-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
||||||
|
<h2 class="admin-post-form__media-picker-title text-lg font-semibold">
|
||||||
|
대표 이미지 선택
|
||||||
|
</h2>
|
||||||
|
<button class="admin-post-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-post-form__media-picker-body max-h-[62vh] overflow-y-auto p-5">
|
||||||
|
<p v-if="isLoadingMedia" class="admin-post-form__media-picker-loading text-sm text-muted">
|
||||||
|
미디어를 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
<div v-else-if="mediaItems.length" class="admin-post-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||||
|
<button
|
||||||
|
v-for="item in mediaItems"
|
||||||
|
:key="item.url"
|
||||||
|
class="admin-post-form__media-picker-item overflow-hidden border border-line bg-white text-left"
|
||||||
|
type="button"
|
||||||
|
@click="selectFeaturedImage(item)"
|
||||||
|
>
|
||||||
|
<img class="admin-post-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||||
|
<span class="admin-post-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="admin-post-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
|
||||||
|
선택할 미디어가 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.17
|
||||||
|
|
||||||
|
### 대표 이미지와 미디어 화면 밀도 개선 결정
|
||||||
|
|
||||||
|
대표 이미지는 URL을 직접 입력하지 않고 미디어 라이브러리에서 선택하거나 새로 업로드하는 흐름으로 통일한다. 게시물 작성자가 파일 URL을 다루지 않아도 되고, 이미 업로드된 이미지를 재사용할 수 있어야 하기 때문이다. 대표 이미지가 설정되면 썸네일과 삭제/변경 액션을 바로 보여준다.
|
||||||
|
|
||||||
|
미디어 화면은 수백 개 이상의 파일이 쌓이는 전제를 기준으로 카드형 목록에서 고밀도 썸네일 갤러리로 바꾼다. 파일 경로, 용량, 사용 현황, 파일명 변경, 삭제 같은 상세 정보는 워드프레스처럼 선택한 이미지의 상세 모달에서 확인하고 처리한다.
|
||||||
|
|
||||||
## 2026-05-01 v0.0.16
|
## 2026-05-01 v0.0.16
|
||||||
|
|
||||||
### 미디어 사용처 표시와 삭제 보호 결정
|
### 미디어 사용처 표시와 삭제 보호 결정
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택 |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리 블록 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리 블록 |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ components/content/
|
|||||||
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
||||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||||
|
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
|
||||||
|
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 삭제/변경 액션을 표시한다.
|
||||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||||
@@ -248,7 +250,8 @@ components/content/
|
|||||||
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||||
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
||||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||||
- 관리자 미디어 화면은 업로드 이미지 목록, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
- 관리자 미디어 화면은 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||||
|
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 클릭 시 열리는 상세 모달에서 표시한다.
|
||||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
|
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
|
||||||
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
|
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
|
||||||
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
||||||
|
- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인
|
||||||
|
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
|
||||||
- [ ] 콜아웃, 토글, 임베드 블록 추가
|
- [ ] 콜아웃, 토글, 임베드 블록 추가
|
||||||
- [ ] 글 작성 중 자동 저장
|
- [ ] 글 작성 중 자동 저장
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.17
|
||||||
|
|
||||||
|
- 관리자 글 작성/수정 폼의 대표 이미지 URL 직접 입력을 이미지 선택 UI로 변경.
|
||||||
|
- 대표 이미지 썸네일, 삭제, 변경, 새 업로드 기능 추가.
|
||||||
|
- 대표 이미지를 기존 미디어 라이브러리에서 선택할 수 있도록 추가.
|
||||||
|
- 관리자 미디어 화면을 고밀도 썸네일 갤러리 구조로 변경.
|
||||||
|
- 미디어 경로, 사용 현황, 용량, 파일명 변경, 삭제 정보를 상세 모달로 이동.
|
||||||
|
- 패키지 버전을 0.0.17로 갱신.
|
||||||
|
|
||||||
## v0.0.16
|
## v0.0.16
|
||||||
|
|
||||||
- 관리자 미디어 목록에 게시물/페이지 사용 현황 표시 추가.
|
- 관리자 미디어 목록에 게시물/페이지 사용 현황 표시 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.16",
|
"version": "0.0.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.16",
|
"version": "0.0.17",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.16",
|
"version": "0.0.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ const editingUrl = ref('')
|
|||||||
const editingName = ref('')
|
const editingName = ref('')
|
||||||
const deletingUrl = ref('')
|
const deletingUrl = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
const selectedMediaUrl = ref('')
|
||||||
|
|
||||||
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
||||||
default: () => []
|
default: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null)
|
||||||
|
|
||||||
const filteredMediaItems = computed(() => {
|
const filteredMediaItems = computed(() => {
|
||||||
const query = searchText.value.trim().toLowerCase()
|
const query = searchText.value.trim().toLowerCase()
|
||||||
|
|
||||||
@@ -46,16 +49,26 @@ const formatFileSize = (size) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미디어 수정 상태 시작
|
* 미디어 상세 모달 열기
|
||||||
* @param {Object} item - 미디어 항목
|
* @param {Object} item - 미디어 항목
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const startRename = (item) => {
|
const openMediaDetail = (item) => {
|
||||||
|
selectedMediaUrl.value = item.url
|
||||||
editingUrl.value = item.url
|
editingUrl.value = item.url
|
||||||
editingName.value = item.title
|
editingName.value = item.title
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 상세 모달 닫기
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeMediaDetail = () => {
|
||||||
|
selectedMediaUrl.value = ''
|
||||||
|
cancelRename()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미디어 파일명 변경 취소
|
* 미디어 파일명 변경 취소
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -80,7 +93,7 @@ const renameMedia = async () => {
|
|||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await $fetch('/admin/api/media', {
|
const renamedItem = await $fetch('/admin/api/media', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: {
|
body: {
|
||||||
url: editingUrl.value,
|
url: editingUrl.value,
|
||||||
@@ -89,6 +102,7 @@ const renameMedia = async () => {
|
|||||||
})
|
})
|
||||||
cancelRename()
|
cancelRename()
|
||||||
await refresh()
|
await refresh()
|
||||||
|
selectedMediaUrl.value = renamedItem.url
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error?.data?.message || '파일명을 변경하지 못했습니다.'
|
errorMessage.value = error?.data?.message || '파일명을 변경하지 못했습니다.'
|
||||||
}
|
}
|
||||||
@@ -119,6 +133,7 @@ const deleteMedia = async (item) => {
|
|||||||
url: item.url
|
url: item.url
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
closeMediaDetail()
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error?.data?.message || '파일을 삭제하지 못했습니다.'
|
errorMessage.value = error?.data?.message || '파일을 삭제하지 못했습니다.'
|
||||||
@@ -143,7 +158,7 @@ const deleteMedia = async (item) => {
|
|||||||
v-model="searchText"
|
v-model="searchText"
|
||||||
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="파일명 또는 경로 검색"
|
placeholder="파일명, 경로, 사용처 검색"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,40 +166,79 @@ const deleteMedia = async (item) => {
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-8 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||||
<article v-for="item in filteredMediaItems" :key="item.url" class="admin-media__item border border-line bg-white">
|
<button
|
||||||
<img class="admin-media__image aspect-[4/3] w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
v-for="item in filteredMediaItems"
|
||||||
<div class="admin-media__body grid gap-3 p-4 text-sm">
|
:key="item.url"
|
||||||
<div v-if="editingUrl === item.url" class="admin-media__rename grid gap-2">
|
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left"
|
||||||
<input
|
type="button"
|
||||||
v-model="editingName"
|
@click="openMediaDetail(item)"
|
||||||
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm"
|
>
|
||||||
type="text"
|
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||||
@keydown.enter.prevent="renameMedia"
|
<span
|
||||||
>
|
v-if="item.usage.length"
|
||||||
<div class="admin-media__rename-actions flex gap-2">
|
class="admin-media__usage-badge absolute right-1.5 top-1.5 rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
<button class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white" type="button" @click="renameMedia">
|
>
|
||||||
저장
|
{{ item.usage.length }}
|
||||||
</button>
|
</span>
|
||||||
<button class="admin-media__rename-cancel rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="cancelRename">
|
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||||
취소
|
{{ item.name }}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
|
||||||
<strong class="admin-media__name break-all text-ink">{{ item.name }}</strong>
|
<p v-else class="admin-media__empty mt-8 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||||
<p class="admin-media__path break-all text-xs text-muted">{{ item.url }}</p>
|
표시할 미디어가 없습니다.
|
||||||
</template>
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedMedia"
|
||||||
|
class="admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="closeMediaDetail"
|
||||||
|
>
|
||||||
|
<section class="admin-media__modal-panel grid max-h-[86vh] w-full max-w-5xl overflow-hidden bg-white text-ink shadow-xl lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||||
|
<div class="admin-media__preview grid min-h-[20rem] place-items-center bg-[#f5f5f2] p-5">
|
||||||
|
<img class="admin-media__preview-image max-h-[72vh] max-w-full object-contain" :src="selectedMedia.url" :alt="selectedMedia.title">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
|
||||||
|
<div class="admin-media__detail-header flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Attachment
|
||||||
|
</p>
|
||||||
|
<h2 class="admin-media__detail-title mt-1 break-all text-xl font-semibold">
|
||||||
|
{{ selectedMedia.name }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="admin-media__info grid gap-3 text-sm">
|
||||||
|
<div class="admin-media__info-row">
|
||||||
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">경로</dt>
|
||||||
|
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.url }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="admin-media__info-row">
|
||||||
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
|
||||||
|
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="admin-media__info-row">
|
||||||
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">분류</dt>
|
||||||
|
<dd class="admin-media__info-value mt-1">{{ selectedMedia.category }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<p class="admin-media__meta text-xs text-muted">
|
|
||||||
{{ item.category }} · {{ formatFileSize(item.size) }}
|
|
||||||
</p>
|
|
||||||
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||||
<strong class="admin-media__usage-title text-ink">
|
<strong class="admin-media__usage-title text-ink">
|
||||||
사용 현황 {{ item.usage.length }}곳
|
사용 현황 {{ selectedMedia.usage.length }}곳
|
||||||
</strong>
|
</strong>
|
||||||
<ul v-if="item.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
|
<ul v-if="selectedMedia.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
|
||||||
<li v-for="usage in item.usage" :key="`${item.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
|
<li v-for="usage in selectedMedia.usage" :key="`${selectedMedia.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="usage.adminUrl"
|
v-if="usage.adminUrl"
|
||||||
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
|
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
|
||||||
@@ -200,30 +254,45 @@ const deleteMedia = async (item) => {
|
|||||||
사용 중인 곳이 없습니다.
|
사용 중인 곳이 없습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-media__actions flex gap-2">
|
|
||||||
<button
|
<div class="admin-media__rename grid gap-2">
|
||||||
class="admin-media__rename-button rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
<label class="admin-media__rename-label text-xs font-semibold text-muted" for="media-name">
|
||||||
type="button"
|
파일명
|
||||||
:disabled="item.usage.length > 0"
|
</label>
|
||||||
@click="startRename(item)"
|
<input
|
||||||
|
id="media-name"
|
||||||
|
v-model="editingName"
|
||||||
|
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
|
||||||
|
type="text"
|
||||||
|
:disabled="selectedMedia.usage.length > 0"
|
||||||
|
:placeholder="selectedMedia.title"
|
||||||
|
@keydown.enter.prevent="renameMedia"
|
||||||
>
|
>
|
||||||
파일명 변경
|
<p v-if="selectedMedia.usage.length" class="admin-media__locked text-xs text-muted">
|
||||||
|
사용 중인 미디어는 파일명 변경과 삭제가 잠겨 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-media__actions flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="selectedMedia.usage.length > 0 || !editingName"
|
||||||
|
@click="renameMedia"
|
||||||
|
>
|
||||||
|
파일명 저장
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="deletingUrl === item.url || item.usage.length > 0"
|
:disabled="deletingUrl === selectedMedia.url || selectedMedia.usage.length > 0"
|
||||||
@click="deleteMedia(item)"
|
@click="deleteMedia(selectedMedia)"
|
||||||
>
|
>
|
||||||
{{ deletingUrl === item.url ? '삭제 중' : '삭제' }}
|
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</article>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-else class="admin-media__empty mt-8 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
|
||||||
표시할 미디어가 없습니다.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user