From eb4018f92cff1ef25d838e1f52e626c49a59d46d Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 8 Jun 2026 14:57:38 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=AF=B8?= =?UTF-8?q?=EB=94=94=EC=96=B4=20=EC=B9=B4=EB=93=9C=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=20=ED=83=AD=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 6 + docs/deploy.md | 10 +- docs/history.md | 4 + docs/map.md | 4 +- docs/spec.md | 6 +- docs/update.md | 7 + package-lock.json | 4 +- package.json | 2 +- pages/admin/media/index.vue | 185 +++++++++++++++++++++++---- server/utils/media-library.js | 135 ++++++++++++++++++- server/utils/post-thumbnail-image.js | 10 +- 11 files changed, 332 insertions(+), 41 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index d03cc3a..e12bc3a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # 업데이트 요약 +## v1.5.79 + +- 관리자 미디어에서 카드 썸네일을 별도 탭으로 분리했다. +- 카드 썸네일 사용 여부를 원본 대표 이미지 사용처와 연결해 미사용으로 잘못 표시되지 않게 했다. +- 썸네일이 없어 목록에서 원본을 불러오는 대표 이미지를 구분해 표시하도록 했다. + ## v1.5.78 - 게시물 목록 카드에서 원본 대표 이미지 대신 생성된 카드용 썸네일을 우선 사용하도록 개선했다. diff --git a/docs/deploy.md b/docs/deploy.md index af69e17..6e1d740 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.5.78에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.5.79에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -16,6 +16,14 @@ ## 로컬 개발 +### v1.5.79 참고 + +- 추가 DB 마이그레이션은 없다. +- 관리자 미디어 화면에서 일반 미디어 라이브러리에 `thumbs/*-card.webp` 파일이 섞이지 않고, 카드 썸네일 탭에만 표시되는지 확인한다. +- 대표 이미지로 사용 중인 원본에 카드 썸네일이 있으면 카드 썸네일 탭에서 사용 중으로 표시되고 삭제가 차단되는지 확인한다. +- 대표 이미지로 사용 중인 원본에 카드 썸네일이 없으면 원본 항목에 `원본` 배지와 fallback 상태가 표시되는지 확인한다. +- 운영 기존 업로드 중 fallback 상태가 보이면 `npm run images:backfill-post-thumbnails`를 실행한다. + ### v1.5.78 참고 - 추가 DB 마이그레이션은 없다. diff --git a/docs/history.md b/docs/history.md index ef23a34..7196101 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-06-08 v1.5.79 — 카드 썸네일은 별도 탭과 원본 연결 사용처로 관리한다 + +게시물 카드 썸네일은 사용자가 직접 본문에 삽입하는 원본 미디어가 아니라 목록 성능을 위해 자동 생성되는 파생 파일이다. 일반 미디어 라이브러리에 원본과 썸네일을 함께 노출하면 같은 이미지가 두 개씩 보이고, 미사용 정리 과정에서 실제 목록에 쓰는 썸네일을 삭제할 위험이 있다. 따라서 카드 썸네일을 별도 탭으로 분리하고, 사용 여부는 썸네일 URL 자체가 아니라 원본 대표 이미지 사용처에 연결해 판단한다. 원본이 대표 이미지로 쓰이지만 썸네일이 없으면 목록에서 원본을 불러오는 fallback 상태로 표시해 백필 필요 여부를 구분한다. + ## 2026-06-08 v1.5.78 — 공개 목록 이미지는 원본 대신 카드 썸네일을 우선 사용한다 대표 이미지는 상세 화면, OG 이미지, RSS 같은 원본 품질이 필요한 경로에서도 쓰인다. 하지만 메인·목록·태그 카드에서는 작은 썸네일만 필요하므로 사이트 접속만으로 큰 원본 이미지를 내려받는 비용이 크다. 업로드 시점에 640×360 WebP 카드 썸네일을 함께 만들고, 공개 목록 응답에는 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가해 기존 이미지와 외부 URL의 fallback을 유지한다. 기존 업로드 파일은 운영 볼륨에 저장되므로 저장소에 포함하지 않고 백필 명령으로 생성한다. diff --git a/docs/map.md b/docs/map.md index dc7a576..af72fb2 100644 --- a/docs/map.md +++ b/docs/map.md @@ -152,7 +152,7 @@ | pages/admin/pages/index.vue | 페이지 목록, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) | | pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 | | pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 | -| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 현재 필터 결과 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 | +| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 현재 필터 결과 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 원본 fallback·카드 썸네일 사용 배지, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 추천 사이트 대체 텍스트·썸네일 URL, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` | | components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) | | composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 | @@ -275,7 +275,7 @@ | server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 | | server/utils/navigation-items.js | DB 없을 때 기본 네비 항목(UUID id·parentId·isFolder) | | server/utils/navigation-tree.js | 네비 검증·삽입 순서·공개 primary 트리·DFS sort_order 재부여 | -| server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단 | +| server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단·게시물 카드 썸네일 사용처 연결 | | server/repositories/postgres-client.js | PostgreSQL 클라이언트 | | server/repositories/content-repository.js | 콘텐츠 조회 저장소 | | server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·ZIP 생성 워커 저장소 | diff --git a/docs/spec.md b/docs/spec.md index 70633d0..f55e55f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -240,7 +240,9 @@ components/content/ - 파일: `:::file` ~ `:::` (`url`, `title`, `description`, `name`, `size`) — 다운로드 링크 카드 - 렌더링: `ProseVideo.vue`, `ProseAudio.vue`, `ProseFile.vue` - 관리자 슬래시: `/video`, `/audio`, `/file`로 빈 템플릿 삽입 후 URL·메타 수정 -- 관리자 미디어 화면은 미디어 라이브러리 탭에서 전체·이미지·영상·음악·파일 종류 필터와 미사용 필터를 제공한다. 미사용은 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 항목을 의미한다. 비디오 항목은 브라우저에서 초반 프레임을 캔버스로 추출해 목록 썸네일로 표시하고, 추출 실패 시 `video` placeholder를 유지한다. +- 관리자 미디어 화면은 미디어 라이브러리·카드 썸네일·프로필 이미지 탭으로 구분한다. 미디어 라이브러리 탭은 원본 업로드 파일만 표시하고, 게시물 목록용 `thumbs/*-card.webp` 파생 파일은 카드 썸네일 탭에만 표시한다. +- 관리자 미디어 화면의 미디어 라이브러리 탭은 전체·이미지·영상·음악·파일 종류 필터와 미사용 필터를 제공한다. 미사용은 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 항목을 의미한다. 비디오 항목은 브라우저에서 초반 프레임을 캔버스로 추출해 목록 썸네일로 표시하고, 추출 실패 시 `video` placeholder를 유지한다. +- 카드 썸네일 탭의 항목은 원본 대표 이미지가 사용 중이면 `목록 카드 썸네일` 사용처를 가진 것으로 판정한다. 원본 대표 이미지가 사용 중이지만 카드 썸네일 파일이 없으면 원본 항목에 `원본` 배지와 “목록에서 원본 이미지를 불러옴” 상태를 표시한다. 카드 썸네일은 원본과의 연결을 유지하기 위해 폴더 이동과 파일명 변경을 막고, 사용 중이면 삭제도 막는다. - 문단과 줄바꿈 - 관리자 Markdown-first 에디터에서 Enter는 새 문단(마크다운 한 줄)만 사용한다. Shift+Enter·문단 내 hard break는 지원하지 않는다. - 공개 본문 렌더러는 마크다운 한 줄을 한 문단으로 렌더링한다(레거시 줄끝 `\\`/공백 2개 표식은 표시 시 제거). @@ -842,7 +844,7 @@ components/content/ - 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록). - 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB). - 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다. -- 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **프로필 이미지** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. +- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. - 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. - 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다. - 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다. diff --git a/docs/update.md b/docs/update.md index cc8c4cb..b33700f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.5.79 + +- 관리자 미디어: 게시물 카드 썸네일을 일반 미디어 목록에서 분리해 `카드 썸네일` 탭으로 표시하도록 수정. +- 관리자 미디어: 카드 썸네일도 원본 대표 이미지 사용처에 연결해 사용 중으로 판정하도록 수정. +- 관리자 미디어: 원본 대표 이미지에 카드 썸네일이 없으면 목록에서 원본을 불러오는 상태를 `원본` 배지와 상세 상태로 구분하도록 추가. +- 관리자 미디어: 카드 썸네일 폴더 이동·파일명 변경을 차단하고, 사용 중인 카드 썸네일 삭제를 차단하도록 보강. + ## v1.5.78 - 게시물 이미지 업로드: JPG·PNG·WebP 업로드 시 목록 카드용 WebP 썸네일 자동 생성 추가. diff --git a/package-lock.json b/package-lock.json index 177a1b7..0f9cd5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.5.78", + "version": "1.5.79", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.5.78", + "version": "1.5.79", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index faf04c7..87cab49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.78", + "version": "1.5.79", "private": true, "type": "module", "imports": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index d56a912..693d280 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -55,6 +55,20 @@ const { data: mediaItems, refresh } = await useFetch('/admin/api/media', { */ const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avatars/')) +/** + * 게시물 카드 썸네일 디스크 경로 여부 + * @param {Object} item - 미디어 항목 + * @returns {boolean} 게시물 카드 썸네일이면 true + */ +const isPostCardThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/posts/') && item?.url?.includes('/thumbs/') && item?.url?.endsWith('-card.webp')) + +/** + * 미디어 항목이 시스템 파생 파일인지 확인한다. + * @param {Object} item - 미디어 항목 + * @returns {boolean} 시스템 관리 항목이면 true + */ +const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item) + /** * 파일명 변경·삭제·드래그 이동이 제한되는지 여부 * @param {Object} item - 미디어 항목 @@ -62,6 +76,66 @@ const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avat */ const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner) +/** + * 미디어 파일명을 변경할 수 있는지 확인한다. + * @param {Object} item - 미디어 항목 + * @returns {boolean} 변경 가능 여부 + */ +const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item) + +/** + * 미디어 논리 폴더를 변경할 수 있는지 확인한다. + * @param {Object} item - 미디어 항목 + * @returns {boolean} 변경 가능 여부 + */ +const canEditMediaCategory = (item) => !isSystemManagedMediaItem(item) + +/** + * 미디어 카드 배지 라벨을 반환한다. + * @param {Object} item - 미디어 항목 + * @returns {string} 배지 라벨 + */ +const getMediaBadgeLabel = (item) => { + if (item?.avatarOwner) { + return '회원' + } + + if (item?.thumbnailStatus?.role === 'card') { + return '카드' + } + + if (item?.thumbnailStatus?.isFallbackActive) { + return '원본' + } + + if (item?.usage?.length) { + return String(item.usage.length) + } + + return '' +} + +/** + * 미디어 카드 배지 클래스를 반환한다. + * @param {Object} item - 미디어 항목 + * @returns {string} Tailwind 클래스 + */ +const getMediaBadgeClass = (item) => { + if (item?.thumbnailStatus?.isFallbackActive) { + return 'bg-amber-600 text-white' + } + + if (item?.thumbnailStatus?.role === 'card') { + return 'bg-sky-700 text-white' + } + + if (item?.avatarOwner) { + return 'bg-emerald-800 text-white' + } + + return 'bg-[#15171a] text-white' +} + /** * 미디어 항목 종류를 반환한다. * @param {Object} item - 미디어 항목 @@ -69,17 +143,45 @@ const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item */ const getMediaItemKind = (item) => item?.kind || 'image' -const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item))) +const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item) && !isPostCardThumbnailDiskItem(item))) + +const postThumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isPostCardThumbnailDiskItem(item))) const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item))) -const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value)) +const scopeItems = computed(() => { + if (activeTab.value === 'thumbnails') { + return thumbnailMediaItems.value + } + + if (activeTab.value === 'postThumbnails') { + return postThumbnailMediaItems.value + } + + return libraryMediaItems.value +}) + +const systemManagedTab = computed(() => activeTab.value === 'thumbnails' || activeTab.value === 'postThumbnails') + +const activeSystemMediaCount = computed(() => activeTab.value === 'postThumbnails' + ? postThumbnailMediaItems.value.length + : thumbnailMediaItems.value.length) + +const activeSystemMediaTitle = computed(() => activeTab.value === 'postThumbnails' ? '카드 썸네일' : '프로필 이미지') + +const activeSystemMediaHint = computed(() => { + if (activeTab.value === 'postThumbnails') { + return '게시물 목록 카드에서 쓰는 자동 생성 이미지입니다. 원본 대표 이미지가 사용 중이고 이 썸네일 파일이 있으면 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 썸네일은 사용 중으로 표시되며, 썸네일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.' + } + + return '회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 디스크 파일은 삭제되지 않으며 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.' +}) const mediaKindFilterOptions = computed(() => { const baseItems = scopeItems.value.filter((item) => { const folder = activeFolder.value - return activeTab.value === 'thumbnails' + return systemManagedTab.value ? true : (!folder || item.category === folder || item.category?.startsWith(`${folder}/`)) }) @@ -100,7 +202,7 @@ const mediaKindFilterOptions = computed(() => { const unusedMediaCount = computed(() => scopeItems.value.filter((item) => { const folder = activeFolder.value - const matchesFolder = activeTab.value === 'thumbnails' + const matchesFolder = systemManagedTab.value ? true : (!folder || item.category === folder || item.category?.startsWith(`${folder}/`)) @@ -109,7 +211,7 @@ const unusedMediaCount = computed(() => scopeItems.value.filter((item) => { /** * 상단 탭 전환 시 목록 상태를 초기화한다. - * @param {'library' | 'thumbnails'} tab - 선택 탭 + * @param {'library' | 'postThumbnails' | 'thumbnails'} tab - 선택 탭 * @returns {void} */ const setActiveTab = (tab) => { @@ -227,7 +329,7 @@ const filteredMediaItems = computed(() => { const base = scopeItems.value return base.filter((item) => { - const matchesFolder = activeTab.value === 'thumbnails' + const matchesFolder = systemManagedTab.value ? true : (!folder || item.category === folder || item.category?.startsWith(`${folder}/`)) const usageTitles = item.usage?.map((usage) => usage.title) || [] @@ -636,7 +738,7 @@ const dropMediaOnFolder = async (folder) => { * @returns {Promise} 저장 결과 */ const saveMediaCategory = async () => { - if (selectedMedia.value?.avatarOwner) { + if (!canEditMediaCategory(selectedMedia.value)) { return } @@ -664,8 +766,8 @@ const saveMediaCategory = async () => { const renameMedia = async () => { const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value) - if (editingItem && isMediaItemLocked(editingItem)) { - showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 파일명을 바꿀 수 없습니다.') + if (editingItem && !canRenameMediaItem(editingItem)) { + showToast('error', '사용 중인 미디어 또는 카드 썸네일은 파일명을 바꿀 수 없습니다.') return } @@ -795,6 +897,14 @@ watch(filteredMediaUrls, (urls) => { > 미디어 라이브러리 +

- 회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다.
프로필에서 바꾸거나 해제해도 디스크 파일은 삭제되지 않으며 이 목록에 남습니다.
목록이 바로 안 바뀌면 페이지를 새로고침하세요.
관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다. + {{ activeSystemMediaHint }}

@@ -955,7 +1065,7 @@ watch(filteredMediaUrls, (urls) => {

- {{ activeTab === 'thumbnails' ? '썸네일' : (activeFolder || '전체 미디어') }} + {{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }}

{{ filteredMediaItems.length }}개 표시 @@ -1044,16 +1154,11 @@ watch(filteredMediaUrls, (urls) => { {{ getMediaItemKind(item) }} - 회원 - - - {{ item.usage.length }} + {{ getMediaBadgeLabel(item) }} {{ item.name }} @@ -1179,6 +1284,28 @@ watch(filteredMediaUrls, (urls) => {

용량
{{ formatFileSize(selectedMedia.size) }}
+
+
카드 썸네일
+
+ + 자동 생성된 목록 카드용 썸네일입니다. + + + + 원본: {{ selectedMedia.thumbnailStatus.originalUrl }} + +
+
@@ -1193,13 +1320,13 @@ watch(filteredMediaUrls, (urls) => { type="text" list="media-folder-options" placeholder="미분류" - :disabled="Boolean(selectedMedia.avatarOwner)" + :disabled="!canEditMediaCategory(selectedMedia)" @keydown.enter.prevent="saveMediaCategory" >