관리자 미디어 페이지네이션과 라이브 렌더링 개선
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
getEditableCaretOffset,
|
||||
markdownInlineToHtml,
|
||||
markdownMultilineInlineToHtml,
|
||||
readEditableTextFromElement,
|
||||
setEditableCaretOffset
|
||||
} from '../../lib/markdown-inline.js'
|
||||
@@ -160,6 +161,12 @@ const resolvedEnterMode = computed(() => {
|
||||
*/
|
||||
const toEditorHtml = () => markdownInlineToHtml(props.modelValue.replace(/\n/g, ' '))
|
||||
|
||||
/**
|
||||
* modelValue를 멀티라인 편집용 HTML로 변환한다.
|
||||
* @returns {string} HTML
|
||||
*/
|
||||
const toMultilineEditorHtml = () => markdownMultilineInlineToHtml(props.modelValue)
|
||||
|
||||
/**
|
||||
* plainText 모드용 편집 텍스트를 만든다.
|
||||
* @param {string} value - 본문
|
||||
@@ -189,7 +196,9 @@ const syncEditorHtml = (options = {}) => {
|
||||
return
|
||||
}
|
||||
|
||||
rootRef.value.innerHTML = toEditorHtml()
|
||||
rootRef.value.innerHTML = resolvedEnterMode.value === 'multiline'
|
||||
? toMultilineEditorHtml()
|
||||
: toEditorHtml()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -3193,7 +3193,6 @@ onBeforeUnmount(() => {
|
||||
block-class="content-markdown-renderer__quote-line"
|
||||
:model-value="block.text"
|
||||
enter-mode="multiline"
|
||||
plain-text
|
||||
arrow-exit-creates-line
|
||||
preserve-empty-line-on-full-delete
|
||||
empty-markdown-line="> "
|
||||
|
||||
@@ -110,7 +110,7 @@ const onExitBelow = (payload) => {
|
||||
class="content-markdown-toggle-editor"
|
||||
data-editable-scope
|
||||
:title="titleDraft"
|
||||
:default-open="true"
|
||||
:default-open="defaultOpen"
|
||||
animated
|
||||
>
|
||||
<template #title>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.10.16
|
||||
|
||||
- 라이브 글쓰기 인용문 안에서도 색상·굵게 같은 인라인 문법이 보이도록 바꿨다.
|
||||
- 라이브 글쓰기 토글 블록은 저장된 기본 접힘/펼침 상태로 처음 표시한다.
|
||||
- 관리자 미디어 목록을 50·100·200개 단위로 나눠 볼 수 있게 했다.
|
||||
- 관리자 미디어 그리드는 가능한 작은 파생 이미지를 먼저 사용해 원본 이미지 동시 로드를 줄였다.
|
||||
|
||||
## v1.10.15
|
||||
|
||||
- 새 본문 이미지 자동 생성 파일명을 `body-1600`, `body-720`, `body-2560`처럼 해상도 기준으로 정리했다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.15에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.16에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-07-02 v1.10.16 — 라이브 작성과 미디어 관리는 표시 상태를 신뢰 기준으로 맞춘다
|
||||
|
||||
관리자 라이브 모드는 공개 렌더링과 작성 편의 사이에 있지만, 인용문 안의 색상·굵게 토큰이 그대로 보이면 작성자는 실제 공개 화면을 예측하기 어렵다. 따라서 인용 멀티라인 편집도 일반 인라인 렌더러를 사용해 색상·굵게 같은 문법을 표시한다. 토글은 편집 편의를 위해 항상 펼치는 것보다 저장된 기본 접힘 상태를 확인할 수 있어야 하므로, 라이브 모드 진입 시 선언 줄의 기본 상태를 우선 반영한다.
|
||||
|
||||
미디어 화면은 파일이 수백 개로 늘어나는 운영을 전제로 한다. 서버 응답 구조는 유지하되 관리자 화면 렌더링을 50·100·200개 단위로 나누고, 원본 라이브러리의 이미지 카드도 가능한 작은 파생 이미지를 우선 사용해 브라우저가 한 번에 고용량 원본을 많이 내려받는 상황을 줄인다. 상세 모달과 다운로드는 운영자 확인·백업 목적이 있으므로 원본 접근을 계속 유지한다.
|
||||
|
||||
## 2026-07-02 v1.10.15 — 본문 파생 이미지는 해상도형 파일명을 쓴다
|
||||
|
||||
`body`와 `lightbox` 같은 용도명 파일명은 관리자 미디어에서 실제 크기를 바로 판단하기 어렵다. 본문 이미지는 관리자가 용량과 사용처를 자주 확인하는 자산이므로 새 생성 규칙을 `body-1600`, `body-720`, `body-2560` 해상도형 이름으로 바꾼다. 이미 운영 볼륨에 생성된 `body`, `lightbox` 파일은 삭제하지 않고 레거시 본문 이미지로 계속 분류해 사용처 연결과 삭제 잠금을 유지하며, 백필 명령으로 새 해상도형 파일을 생성할 수 있게 한다.
|
||||
|
||||
@@ -154,8 +154,8 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 720w/1600w WebP `srcset` 캐시 버전 우선 표시와 원본 fallback, 단일 이미지·갤러리 통합 라이트박스의 2560px WebP 캐시 버전 우선 표시, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
|
||||
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, 위/아래 인접 블록 이동 시 편집 스크롤 추적, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용 인라인 색상·굵게 렌더링, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 토글 저장 기본 펼침·닫힘 상태 반영, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 720w/1600w WebP `srcset` 캐시 버전 우선 표시와 원본 fallback, 단일 이미지·갤러리 통합 라이트박스의 2560px WebP 캐시 버전 우선 표시, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
|
||||
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 멀티라인 인라인 마크다운 렌더링, plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, 위/아래 인접 블록 이동 시 편집 스크롤 추적, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
|
||||
| components/content/ContentMarkdownInlineSegments.vue | 공개·라이브 본문 공통 인라인 세그먼트 렌더링(링크·굵게·기울임·인라인 코드·첨자·인라인 색상), 링크·색상 내부 중첩 인라인 렌더링, 사용자 화면 fragment 주석 반복을 줄이는 단일 루트 구조 |
|
||||
| components/content/ContentMarkdownBlockGroup.vue | Markdown 본문 블록 단위 렌더 그룹, 라이브 삽입 영역을 조건부로만 DOM에 추가해 공개 화면 fragment 주석 감소 |
|
||||
| lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 |
|
||||
@@ -169,7 +169,7 @@
|
||||
| components/content/ContentMarkdownCalloutEditor.vue | 라이브 모드 콜아웃 멀티라인 본문 편집, 제목·아이콘 표시 여부·배경 반영, 내부 여러 줄 범위 선택 |
|
||||
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 선택 제목, 인용과 같은 배경 프리셋, 헤더 아래 본문 줄바꿈) |
|
||||
| components/content/ProseToggle.vue | Toggle 카드(펼침 애니메이션, chevron 트리거) |
|
||||
| components/content/ContentMarkdownToggleEditor.vue | 라이브 모드 토글 제목·본문 인라인 편집 |
|
||||
| components/content/ContentMarkdownToggleEditor.vue | 라이브 모드 토글 제목·본문 인라인 편집, 저장된 기본 펼침·닫힘 상태 초기 반영 |
|
||||
| components/content/ProseVideo.vue | `:::video` 공개 비디오 카드 |
|
||||
| components/content/ProseAudio.vue | `:::audio` 공개 오디오 플레이어 카드 |
|
||||
| components/content/ProseFile.vue | `:::file` 공개 다운로드 파일 카드, 제목·파일명이 같으면 용량 중심 보조 정보 표시 |
|
||||
@@ -194,7 +194,7 @@
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 제목 아래 주소·공개 보기 hover 아이콘, 홈 표시·섹션·추천 배지, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 홈 Pages 진열장 노출 메타, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 홈 Pages 진열장 노출 메타, 설정 패널 삭제, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/본문 이미지/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 라이브러리 파일 종류 필터와 미사용 토글, 시스템 탭 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, 현재 필터 결과의 삭제 가능 항목 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일·본문 이미지 사용 배지(파생 탭의 해당 배지는 숨김), 대표 이미지 원본 카드 썸네일 생성·재생성, 본문 이미지 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/본문 이미지/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 라이브러리 파일 종류 필터와 미사용 토글, 시스템 탭 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, 50·100·200개 단위 페이지네이션과 현재 페이지 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일·본문 이미지 사용 배지(파생 탭의 해당 배지는 숨김), 그리드용 작은 파생 이미지 우선 표시와 원본 fallback, 대표 이미지 원본 카드 썸네일 생성·재생성, 본문 이미지 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 메인 네비게이션/페이지 섹션/하단 네비게이션/추천 사이트 탭, 각 탭 작업줄 왼쪽 추가·오른쪽 저장 버튼, 메인·하단 메뉴 추가 모달, 페이지 섹션 추가 모달·수정·삭제·섹션 드래그 정렬·섹션별 카드형 페이지 순서 변경 모달(추천 우선 표시·섹션에서 제거 포함), 추천 사이트는 공개와 같은 카드 목록+편집 모달(전체 URL·썸네일·파비콘 갱신), 행 more vert(삭제), 저장 대상별 버튼(`네비게이션 저장`/`페이지 섹션 저장`), `useAdminToast` |
|
||||
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
|
||||
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
|
||||
|
||||
12
docs/spec.md
12
docs/spec.md
@@ -92,7 +92,7 @@
|
||||
- 공개 Markdown 본문 블록 렌더링은 `ContentMarkdownBlockGroup`으로 블록 단위 루트를 정리하고, 인아티클 광고는 별도 `v-if` 대신 렌더 블록 목록에 삽입한다.
|
||||
- Markdown 리스트 마커는 항목이 여러 줄로 줄바꿈되어도 항목 전체 높이 중앙이 아니라 첫 줄 기준으로 정렬한다.
|
||||
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다. 표 헤더와 셀 내부의 인라인 링크·굵게·기울임·인라인 코드를 렌더링하고, 셀 내부 `<br>` 또는 `<br/>`은 줄바꿈으로 표시한다. 링크 텍스트 안의 `**굵게**` 같은 중첩 인라인 문법도 렌더링한다.
|
||||
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다.
|
||||
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다. 인용 본문 안의 `::red[**텍스트**]` 같은 색상·굵게·링크 등 인라인 문법은 일반 문단과 같은 규칙으로 렌더링한다.
|
||||
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 텍스트를 선택한 뒤 `Cmd/Ctrl+K`를 누르면 `[선택 텍스트](https://)` 인라인 링크 마크다운을 삽입하고 URL 부분을 선택한다. 에디터 되돌리기·다시 실행은 본문 값과 함께 선택 위치를 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
|
||||
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 블로그 소개·Follow 대신 본문 H1~H3 제목 기반 목차를 최상단에 표시한다. 목차는 별도 최대 높이와 내부 스크롤을 두지 않고 전체 목록이 먼저 펼쳐지며, 왼쪽 기준선과 브랜드 컬러 활성 항목 표시선을 둔다. 목차 항목은 최소 24px 높이와 세로 중앙 정렬을 사용하며, 2줄 이상 제목에서는 항목 높이에 맞춰 활성 표시선도 함께 늘어난다. 활성 표시선은 항목의 가상 요소 배경 막대로 그려 텍스트 시작점이 밀리지 않게 한다. 목차 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 목차 항목을 강조하고, 목차 항목이 많으면 오른쪽 사이드바 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 목차를 숨긴다. 게시물 상세에서는 일반 오른쪽 사이드 광고 대신 게시물 사이드 광고 코드를 목차 아래에 표시하고, 왼쪽 사이드바에는 게시물 광고를 표시하지 않는다. 게시물 상세 목차와 게시물 사이드 광고 슬롯은 하단 구분선을 표시하지 않는다.
|
||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||
@@ -198,7 +198,7 @@ layouts/
|
||||
- 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
|
||||
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 메인 태그를 드롭다운에서 추가할 수 있다. 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천하고, 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. 게시물별 태그 최대 개수는 기본 5개이며, 사이트 설정 POST 설정의 `postTagLimit` 값으로 1~10개 범위에서 조절한다.
|
||||
- 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다.
|
||||
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 전체 선택은 현재 검색·필터 결과만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다.
|
||||
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 미디어 목록은 화면 렌더링 기준 50·100·200개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다.
|
||||
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||
@@ -723,7 +723,7 @@ components/content/
|
||||
- 라이브 모드 갤러리 이미지를 블록 사이 얇은 삽입선(또는 문서 맨 아래 삽입선)에 드롭하면 해당 위치에 단일 이미지 마크다운 줄을 삽입하고 갤러리에서 제거한다(`extract-gallery-image`). 갤러리에 이미지가 1장만 남으면 갤러리 블록을 단일 이미지 줄로 바꾸고, 0장이면 갤러리 블록을 제거한다.
|
||||
- `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다.
|
||||
- 인용(`>`) 블록은 첫 인용 줄에 `> [!bg=yellow]` 또는 `> {bg=yellow}` 옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이며, 옵션이 없으면 회색 기본 인용 스타일을 쓴다.
|
||||
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하되, 코드 fenced 블록 본문 안에서는 `/`를 일반 텍스트로 둔다. 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 소스 모드와 라이브 모드에서 현재 줄을 삭제하며, 소스 모드에서 여러 줄이 선택되어 있으면 선택 범위가 걸친 줄을 함께 삭제한다. 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. `Cmd/Ctrl+Z`는 에디터 본문 변경을 되돌리고, `Cmd/Ctrl+Shift+Z` 또는 `Ctrl+Y`는 다시 실행한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 기본 줄번호 숨김이고, 줄번호 표시를 켜면 선언 줄에 `line-numbers`를 저장한다. 기존 `linenos`, `nolinenos`, `no-line-numbers` 토큰은 읽기 호환으로만 유지한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
|
||||
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. 인용 멀티라인 편집 영역도 일반 인라인 렌더러를 사용해 색상·굵게 등 인라인 문법을 표시한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하되, 코드 fenced 블록 본문 안에서는 `/`를 일반 텍스트로 둔다. 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 소스 모드와 라이브 모드에서 현재 줄을 삭제하며, 소스 모드에서 여러 줄이 선택되어 있으면 선택 범위가 걸친 줄을 함께 삭제한다. 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. `Cmd/Ctrl+Z`는 에디터 본문 변경을 되돌리고, `Cmd/Ctrl+Shift+Z` 또는 `Ctrl+Y`는 다시 실행한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 기본 줄번호 숨김이고, 줄번호 표시를 켜면 선언 줄에 `line-numbers`를 저장한다. 기존 `linenos`, `nolinenos`, `no-line-numbers` 토큰은 읽기 호환으로만 유지한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
|
||||
- 라이브 모드 공통 편집기는 한글 IME 조합 확정 Enter를 문단·빈 줄의 분리, 제목·목록의 아래 줄 삽입, 토글 제목의 본문 이동, 인용·콜아웃·코드·토글 본문의 텍스트 줄바꿈에 동일하게 연결한다. IME가 확정 Enter의 `keydown`을 전달하지 않고 `keyup`만 남기는 경우에도 조합 종료 직후 Enter로 보고 같은 동작을 실행한다. Shift+방향키 범위 선택은 `ContentMarkdownRenderer` Selection Bridge가 인접 편집 블록까지 확장한다. 단일 줄 블록의 Shift+위/아래는 커서 위치와 상관없이 현재 열 기준으로 이전·다음 편집 블록까지 확장하고, 멀티라인 편집 영역은 첫 줄·마지막 줄 경계에서 `data-source-line`~`data-source-line-end` 줄 범위로 탐색한다. `Cmd/Ctrl+A` 1회는 현재 블록 전체, 짧은 시간 안 2회는 라이브 본문 전체를 선택한다. 교차 블록·전체 선택 상태에서 **Backspace**·**Delete**·**Cmd/Ctrl+X**는 선택 범위를 마크다운 줄로 변환해 `content-replace`로 본문을 갱신한다. 콜아웃·인용 내부 전체 선택 삭제는 블록을 제거하지 않고 빈 본문 줄을 남긴다. 블록 이동 단축키는 Shift 조합일 때 실행하지 않으며, 선택 제스처가 우선한다. 콜아웃 본문은 하나의 멀티라인 편집 영역으로 유지해 `Shift+방향키` 선택이 내부 여러 줄을 가로지를 수 있게 한다. 라이브 멀티라인 블록은 브라우저 기본 DOM 줄 생성 대신 텍스트 값 치환 방식으로 줄바꿈을 삽입하며, contenteditable 값을 읽을 때 끝 줄바꿈을 보존해 첫 줄 빈 줄과 후행 빈 줄이 소스·라이브 모드 전환 또는 저장 중 유실되지 않게 한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들고 커서를 해당 문단으로 이동하지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다.
|
||||
- 인라인 마크다운은 Obsidian식 `$...$` 첨자 토큰과 `::color[텍스트]` 색상 토큰을 지원한다. `$H_2O$`는 `H`+아래첨자 `2`+`O`, `$2^8$`은 `2`+위첨자 `8`, `$_B^AR$`는 아래첨자 `B`와 위첨자 `AR`로 렌더링한다. 첨자 본문에 공백·기호가 필요하면 `$_{...}$`, `$^{...}$` 형식도 허용한다. 색상 토큰은 `::gray[덜 중요한 말]`, `::red[**중요한 말**]`처럼 저장하며 `gray`, `blue`, `green`, `yellow`, `red`, `purple`만 허용한다. 색상 토큰 내부의 굵게·기울임·인라인 코드·링크·첨자는 재귀적으로 렌더링한다.
|
||||
- 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다.
|
||||
@@ -792,7 +792,7 @@ components/content/
|
||||
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`, `title`을 저장할 수 있다. 예: `:::callout emoji=none bg=blue title="주의사항"`
|
||||
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
|
||||
- 콜아웃 배경 프리셋은 인용 블록과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`를 지원한다.
|
||||
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다.
|
||||
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 저장된 기본 펼침·닫힘 상태로 처음 표시한 뒤 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다.
|
||||
- 링크 블록은 `:::link` fenced block 안에 URL을 저장하고 `ProseEmbed`로 렌더링한다. 이미지 파일 URL은 링크 블록이 아니라 이미지 블록으로 저장한다.
|
||||
- iframe 블록은 `:::iframe` fenced block 안에 iframe HTML을 저장한다. 렌더링 시 `src`, `title`, `allow`, `width`, `height`, `allowfullscreen` 등 제한된 속성만 추출해 sandbox iframe으로 표시한다.
|
||||
- HTML 블록은 `:::html` fenced block 안에 사용자 HTML을 저장하고 `ProseHtml`로 렌더링한다. 본문 HTML에서는 script, iframe, object, form 계열 태그와 이벤트 속성, `javascript:` URL을 제거한다. iframe은 `/iframe` 블록을 사용한다.
|
||||
@@ -921,10 +921,10 @@ components/content/
|
||||
- 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
|
||||
- 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB). 관리자 게시물·페이지용 미디어 업로드의 1회 파일 수와 전체 용량은 사이트 설정 POST 설정의 업로드 제한을 따른다.
|
||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**본문 이미지**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp`와 `*-page-cover.webp` 파일만, 본문 이미지 탭에서는 `/posts/YYYY/MM/thumbs/*-body-1600.webp`, `*-body-720.webp`, `*-body-2560.webp` 파일과 레거시 `*-body.webp`, `*-lightbox.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 본문 이미지 탭의 상세 정보는 1600px, 720px, 2560px 용도를 구분해 표시한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 배지는 표시하지 않는다. 파생 이미지 목록·상세 미리보기는 파일 수정 시각과 용량을 캐시 버전으로 붙여 재생성 직후에도 새 파일을 확인할 수 있게 한다.
|
||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**본문 이미지**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp`와 `*-page-cover.webp` 파일만, 본문 이미지 탭에서는 `/posts/YYYY/MM/thumbs/*-body-1600.webp`, `*-body-720.webp`, `*-body-2560.webp` 파일과 레거시 `*-body.webp`, `*-lightbox.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 본문 이미지 탭의 상세 정보는 1600px, 720px, 2560px 용도를 구분해 표시한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 배지는 표시하지 않는다. 미디어 그리드는 50·100·200개 단위 페이지네이션을 사용하며, 원본 이미지 항목도 가능한 경우 720px 본문 파생 이미지 또는 카드·Pages 커버 썸네일을 우선 표시하고 실패 시 원본으로 되돌린다. 파생 이미지 목록·상세 미리보기는 파일 수정 시각과 용량을 캐시 버전으로 붙여 재생성 직후에도 새 파일을 확인할 수 있게 한다.
|
||||
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
|
||||
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다. 현재 필터 결과의 전체 선택은 사용 중이 아니어서 삭제 가능한 항목만 대상으로 한다.
|
||||
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 현재 페이지 안의 범위 선택이 가능하다. 현재 페이지 선택은 화면에 보이는 항목 중 사용 중이 아니어서 삭제 가능한 항목만 대상으로 한다.
|
||||
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
||||
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
|
||||
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(프로필 이미지 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.16
|
||||
|
||||
- 관리자 라이브 인용 편집에서 `::red[**텍스트**]` 같은 인라인 색상·굵게 문법을 렌더링하도록 수정.
|
||||
- 관리자 라이브 토글 블록이 저장된 기본 펼침·닫힘 상태로 처음 표시되도록 수정.
|
||||
- 관리자 미디어 화면에 50·100·200개 단위 페이지네이션과 번호형 빠른 이동을 추가.
|
||||
- 관리자 미디어 그리드 이미지가 원본 대신 가능한 작은 파생 이미지를 우선 사용하고, 실패 시 원본으로 fallback하도록 수정.
|
||||
|
||||
## v1.10.15
|
||||
|
||||
- 새 게시물 본문 파생 이미지 파일명을 `*-body-1600.webp`, `*-body-720.webp`, `*-body-2560.webp` 해상도형 규칙으로 정리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.15",
|
||||
"version": "1.10.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.15",
|
||||
"version": "1.10.16",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.15",
|
||||
"version": "1.10.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeUploadMaxTotalSize
|
||||
} from '~/lib/upload-size-limit.js'
|
||||
import {
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
isPageCoverThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl,
|
||||
@@ -21,6 +22,9 @@ definePageMeta({
|
||||
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
|
||||
const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
||||
|
||||
/** 관리자 미디어 한 페이지 표시 개수 옵션 */
|
||||
const mediaPageSizeOptions = [50, 100, 200]
|
||||
|
||||
/** 회원 등급 코드 → 미디어 배지 라벨 */
|
||||
const memberRoleBadgeLabels = {
|
||||
owner: '소유자',
|
||||
@@ -53,6 +57,8 @@ const regeneratingBodyThumbnailUrl = ref('')
|
||||
const selectedMediaUrl = ref('')
|
||||
const selectedMediaUrls = ref([])
|
||||
const lastSelectedIndex = ref(-1)
|
||||
const mediaPage = ref(1)
|
||||
const mediaPageSize = ref(50)
|
||||
const draggingUrls = ref([])
|
||||
const uploadInputRef = ref(null)
|
||||
const uploadingFiles = ref(false)
|
||||
@@ -147,6 +153,48 @@ const getMediaPreviewUrl = (item) => {
|
||||
return withGeneratedImageCacheVersion(url, `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 미디어 그리드에서 사용할 가벼운 이미지 URL을 반환한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {string} 그리드 표시 URL
|
||||
*/
|
||||
const getMediaGridPreviewUrl = (item) => {
|
||||
if (getMediaItemKind(item) !== 'image') {
|
||||
return getMediaPreviewUrl(item)
|
||||
}
|
||||
|
||||
if (isSystemManagedMediaItem(item)) {
|
||||
return getMediaPreviewUrl(item)
|
||||
}
|
||||
|
||||
if (item?.bodyThumbnailStatus?.hasThumbnail) {
|
||||
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(item.url), `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
|
||||
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
return getMediaPreviewUrl(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리드용 파생 이미지 로드 실패 시 원본 이미지로 되돌린다.
|
||||
* @param {Event} event - 이미지 오류 이벤트
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const onMediaGridImageError = (event, item) => {
|
||||
const image = event.target
|
||||
|
||||
if (!(image instanceof HTMLImageElement) || image.dataset.fallbackApplied === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
image.dataset.fallbackApplied = 'true'
|
||||
image.src = getMediaPreviewUrl(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
|
||||
* @param {Object} item - 미디어 항목
|
||||
@@ -544,7 +592,19 @@ const filteredMediaItems = computed(() => {
|
||||
|
||||
const filteredMediaUrls = computed(() => filteredMediaItems.value.map((item) => item.url))
|
||||
|
||||
const filteredSelectableMediaItems = computed(() => filteredMediaItems.value.filter((item) => !isMediaItemLocked(item)))
|
||||
const mediaPageCount = computed(() => Math.max(1, Math.ceil(filteredMediaItems.value.length / mediaPageSize.value)))
|
||||
|
||||
const mediaPageStartIndex = computed(() => filteredMediaItems.value.length
|
||||
? (mediaPage.value - 1) * mediaPageSize.value
|
||||
: 0)
|
||||
|
||||
const mediaPageDisplayStart = computed(() => filteredMediaItems.value.length ? mediaPageStartIndex.value + 1 : 0)
|
||||
|
||||
const mediaPageEndIndex = computed(() => Math.min(mediaPageStartIndex.value + mediaPageSize.value, filteredMediaItems.value.length))
|
||||
|
||||
const paginatedMediaItems = computed(() => filteredMediaItems.value.slice(mediaPageStartIndex.value, mediaPageEndIndex.value))
|
||||
|
||||
const filteredSelectableMediaItems = computed(() => paginatedMediaItems.value.filter((item) => !isMediaItemLocked(item)))
|
||||
|
||||
const filteredSelectableMediaUrls = computed(() => filteredSelectableMediaItems.value.map((item) => item.url))
|
||||
|
||||
@@ -556,6 +616,64 @@ const isAllFilteredMediaSelected = computed(() => {
|
||||
return filteredSelectableMediaUrls.value.every((url) => selectedMediaUrls.value.includes(url))
|
||||
})
|
||||
|
||||
const mediaPaginationItems = computed(() => {
|
||||
const total = mediaPageCount.value
|
||||
const current = mediaPage.value
|
||||
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
|
||||
const pages = new Set([1, total, current - 1, current, current + 1])
|
||||
|
||||
if (current <= 4) {
|
||||
pages.add(2)
|
||||
pages.add(3)
|
||||
pages.add(4)
|
||||
pages.add(5)
|
||||
}
|
||||
|
||||
if (current >= total - 3) {
|
||||
pages.add(total - 4)
|
||||
pages.add(total - 3)
|
||||
pages.add(total - 2)
|
||||
pages.add(total - 1)
|
||||
}
|
||||
|
||||
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
|
||||
return orderedPages.reduce((items, page, index) => {
|
||||
const previousPage = orderedPages[index - 1]
|
||||
|
||||
if (previousPage && page - previousPage > 1) {
|
||||
items.push(`ellipsis-${previousPage}-${page}`)
|
||||
}
|
||||
|
||||
items.push(page)
|
||||
return items
|
||||
}, [])
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 미디어 페이지를 변경한다.
|
||||
* @param {number} page - 이동할 페이지
|
||||
* @returns {void}
|
||||
*/
|
||||
const setMediaPage = (page) => {
|
||||
mediaPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPageCount.value)
|
||||
lastSelectedIndex.value = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 미디어 페이지 표시 개수를 변경한다.
|
||||
* @param {Event} event - select 변경 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const setMediaPageSize = (event) => {
|
||||
mediaPageSize.value = Number(event.target.value) || 50
|
||||
mediaPage.value = 1
|
||||
lastSelectedIndex.value = -1
|
||||
}
|
||||
|
||||
const selectedDeletableMediaItems = computed(() => {
|
||||
const selectedUrlSet = new Set(selectedMediaUrls.value)
|
||||
|
||||
@@ -624,7 +742,7 @@ const toggleMediaSelection = (item, index, event) => {
|
||||
if (event.shiftKey && lastSelectedIndex.value >= 0) {
|
||||
const startIndex = Math.min(lastSelectedIndex.value, index)
|
||||
const endIndex = Math.max(lastSelectedIndex.value, index)
|
||||
const rangeUrls = filteredMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url)
|
||||
const rangeUrls = paginatedMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url)
|
||||
const merged = new Set([...selectedMediaUrls.value, ...rangeUrls])
|
||||
selectedMediaUrls.value = [...merged]
|
||||
lastSelectedIndex.value = index
|
||||
@@ -647,7 +765,7 @@ const clearMediaSelection = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 필터링된 미디어 전체 선택을 토글한다.
|
||||
* 현재 페이지에 표시된 미디어 전체 선택을 토글한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleFilteredMediaSelection = () => {
|
||||
@@ -666,7 +784,7 @@ const toggleFilteredMediaSelection = () => {
|
||||
...selectedMediaUrls.value,
|
||||
...filteredSelectableMediaUrls.value
|
||||
])]
|
||||
lastSelectedIndex.value = filteredMediaItems.value.length - 1
|
||||
lastSelectedIndex.value = paginatedMediaItems.value.length - 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1179,6 +1297,18 @@ watch(filteredMediaUrls, (urls) => {
|
||||
lastSelectedIndex.value = -1
|
||||
}
|
||||
})
|
||||
|
||||
watch([activeTab, activeFolder, activeMediaKind, showUnusedOnly, searchText], () => {
|
||||
mediaPage.value = 1
|
||||
lastSelectedIndex.value = -1
|
||||
})
|
||||
|
||||
watch(mediaPageCount, (pageCount) => {
|
||||
if (mediaPage.value > pageCount) {
|
||||
mediaPage.value = pageCount
|
||||
lastSelectedIndex.value = -1
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1381,10 +1511,22 @@ watch(filteredMediaUrls, (urls) => {
|
||||
{{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }}
|
||||
</h2>
|
||||
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
|
||||
{{ filteredMediaItems.length }}개 표시
|
||||
{{ filteredMediaItems.length }}개 중 {{ mediaPageDisplayStart }}-{{ mediaPageEndIndex }}개 표시
|
||||
</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">
|
||||
<span>표시</span>
|
||||
<select
|
||||
class="admin-media__page-size-select rounded border border-line bg-white px-2 py-2 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>
|
||||
</label>
|
||||
<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"
|
||||
@@ -1393,7 +1535,7 @@ watch(filteredMediaUrls, (urls) => {
|
||||
:disabled="!filteredSelectableMediaUrls.length"
|
||||
@click="toggleFilteredMediaSelection"
|
||||
>
|
||||
{{ isAllFilteredMediaSelected ? '전체 해제' : '전체 선택' }}
|
||||
{{ isAllFilteredMediaSelected ? '현재 페이지 해제' : '현재 페이지 선택' }}
|
||||
</button>
|
||||
<div v-if="selectedMediaUrls.length" class="admin-media__selection flex flex-wrap items-center gap-2 rounded border border-line bg-white px-3 py-2 text-xs">
|
||||
<strong class="admin-media__selection-count text-ink">{{ selectedMediaUrls.length }}개 선택됨</strong>
|
||||
@@ -1414,7 +1556,7 @@ watch(filteredMediaUrls, (urls) => {
|
||||
|
||||
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
<div
|
||||
v-for="(item, index) in filteredMediaItems"
|
||||
v-for="(item, index) in paginatedMediaItems"
|
||||
:key="item.url"
|
||||
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
|
||||
:draggable="activeTab === 'library'"
|
||||
@@ -1453,8 +1595,11 @@ watch(filteredMediaUrls, (urls) => {
|
||||
<img
|
||||
v-if="getMediaItemKind(item) === 'image'"
|
||||
class="admin-media__image aspect-square w-full bg-surface object-cover"
|
||||
:src="getMediaPreviewUrl(item)"
|
||||
:src="getMediaGridPreviewUrl(item)"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onMediaGridImageError($event, item)"
|
||||
>
|
||||
<AdminMediaVideoThumbnail
|
||||
v-else-if="getMediaItemKind(item) === 'video'"
|
||||
@@ -1490,6 +1635,50 @@ watch(filteredMediaUrls, (urls) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-if="mediaPageCount > 1"
|
||||
class="admin-media__pagination mt-6 flex flex-wrap items-center justify-center gap-2"
|
||||
aria-label="미디어 페이지"
|
||||
>
|
||||
<button
|
||||
class="admin-media__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="mediaPage <= 1"
|
||||
aria-label="이전 페이지"
|
||||
@click="setMediaPage(mediaPage - 1)"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<template v-for="item in mediaPaginationItems" :key="item">
|
||||
<span v-if="typeof item === 'string'" class="admin-media__pagination-ellipsis px-2 text-sm font-semibold text-muted">
|
||||
...
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
class="admin-media__pagination-page grid size-9 place-items-center rounded-full text-sm font-semibold transition"
|
||||
:class="item === mediaPage ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-white hover:text-ink'"
|
||||
type="button"
|
||||
:aria-current="item === mediaPage ? 'page' : undefined"
|
||||
@click="setMediaPage(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="admin-media__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="mediaPage >= mediaPageCount"
|
||||
aria-label="다음 페이지"
|
||||
@click="setMediaPage(mediaPage + 1)"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p v-else class="admin-media__empty mt-5 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||
표시할 미디어가 없습니다.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user