v1.12.21 관리자 미디어 조작 개선 및 CSP 관찰 안내 정리

This commit is contained in:
2026-09-28 16:52:14 +09:00
parent 5482318b84
commit 6886c57d04
18 changed files with 488 additions and 60 deletions

View File

@@ -43,6 +43,14 @@ const props = defineProps({
deletingFolder: {
type: String,
default: ''
},
draggingCount: {
type: Number,
default: 0
},
busy: {
type: Boolean,
default: false
}
})
@@ -53,6 +61,8 @@ const emit = defineEmits([
'select-folder'
])
const hoveredFolder = ref(null)
/**
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
* @param {string} folder - 폴더 경로
@@ -80,6 +90,54 @@ const getFolderName = (folder) => String(folder || '').split('/').filter(Boolean
* @returns {number} 폴더 깊이
*/
const getFolderDepth = (folder) => Math.max(String(folder || '').split('/').filter(Boolean).length - 1, 0)
/**
* 내부 미디어 드래그만 폴더 이동 대상으로 허용한다.
* @param {DragEvent} event - 드래그 이벤트
* @param {string} folder - 대상 폴더
* @returns {void}
*/
const onFolderDragOver = (event, folder) => {
if (!props.draggingCount || props.busy || isThumbnailFolderPath(folder) || (props.deletingFolder && props.deletingFolder === folder)) {
return
}
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
hoveredFolder.value = folder
}
/**
* 폴더 내부 요소 간 이동을 제외하고 드롭 강조를 해제한다.
* @param {DragEvent} event - 드래그 이탈 이벤트
* @returns {void}
*/
const onFolderDragLeave = (event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
hoveredFolder.value = null
}
}
/**
* 유효한 내부 드래그에 한해 폴더 이동을 요청한다.
* @param {DragEvent} event - 드롭 이벤트
* @param {string} folder - 대상 폴더
* @returns {void}
*/
const onFolderDrop = (event, folder) => {
event.preventDefault()
hoveredFolder.value = null
if (props.draggingCount && !props.busy && !isThumbnailFolderPath(folder) && props.deletingFolder !== folder) {
emit('drop-folder', folder)
}
}
watch(() => props.draggingCount, (count) => {
if (!count) {
hoveredFolder.value = null
}
})
</script>
<template>
@@ -89,13 +147,19 @@ const getFolderDepth = (folder) => Math.max(String(folder || '').split('/').filt
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface"
:class="!props.activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
:class="hoveredFolder === '' ? 'bg-sky-50 text-sky-900 ring-2 ring-inset ring-sky-500 hover:bg-sky-50' : !props.activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
type="button"
:disabled="props.busy"
@click="emit('select-folder', '')"
@dragover.prevent
@drop.prevent="emit('drop-folder', '미분류')"
@dragenter="onFolderDragOver($event, '')"
@dragover="onFolderDragOver($event, '')"
@dragleave="onFolderDragLeave"
@drop="onFolderDrop($event, '미분류')"
>
<span>전체 미디어</span>
<span>
전체 미디어
<span v-if="hoveredFolder === ''" class="admin-media__folder-drop-hint mt-1 block text-xs font-semibold">놓으면 {{ props.draggingCount }}개를 미분류로 이동</span>
</span>
<span>{{ props.libraryCount }}</span>
</button>
@@ -103,18 +167,26 @@ const getFolderDepth = (folder) => Math.max(String(folder || '').split('/').filt
<div
v-for="folder in props.folders"
:key="folder"
class="admin-media__folder-row flex items-stretch rounded"
:class="props.activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
class="admin-media__folder-row flex items-stretch rounded transition-colors"
:class="hoveredFolder === folder ? 'admin-media__folder-row--drop-target bg-sky-50 text-sky-900 ring-2 ring-inset ring-sky-500' : props.activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
@dragenter="onFolderDragOver($event, folder)"
@dragover="onFolderDragOver($event, folder)"
@dragleave="onFolderDragLeave"
@drop="onFolderDrop($event, folder)"
>
<button
class="admin-media__folder-button flex min-w-0 flex-1 items-center justify-between rounded py-2 pr-3 text-left text-sm"
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
type="button"
:disabled="props.busy"
:title="folder"
:aria-current="props.activeFolder === folder ? 'true' : undefined"
@click="emit('select-folder', folder)"
@dragover.prevent
@drop.prevent="emit('drop-folder', folder)"
>
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
<span class="admin-media__folder-label min-w-0">
<span class="admin-media__folder-name block truncate">{{ getFolderName(folder) }}</span>
<span v-if="hoveredFolder === folder" class="admin-media__folder-drop-hint mt-1 block text-xs font-semibold">놓으면 {{ props.draggingCount }}개 이동</span>
</span>
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ props.folderMediaCounts[folder] || 0 }}</span>
</button>
<AdminRowMoreMenu
@@ -124,14 +196,14 @@ const getFolderDepth = (folder) => Math.max(String(folder || '').split('/').filt
menu-label="폴더 메뉴"
size="sm"
class="mr-0.5 shrink-0"
:inverse="props.activeFolder === folder"
:busy="props.deletingFolder === folder"
:inverse="props.activeFolder === folder && hoveredFolder !== folder"
:busy="props.busy || props.deletingFolder === folder"
>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
:disabled="props.deletingFolder === folder"
:disabled="props.busy || props.deletingFolder === folder"
@click="emit('remove-folder', folder)"
>
폴더 삭제
@@ -144,6 +216,7 @@ const getFolderDepth = (folder) => Math.max(String(folder || '').split('/').filt
<button
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
type="button"
:disabled="props.busy"
@click="emit('create-folder')"
>
폴더 추가

View File

@@ -31,11 +31,20 @@ const props = defineProps({
selectedUrls: {
type: Array,
default: () => []
},
draggingUrls: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'drag-start',
'drag-end',
'image-error',
'open-detail',
'toggle-selection'
@@ -55,9 +64,16 @@ const isMediaSelected = (item) => props.selectedUrls.includes(item?.url)
v-for="(item, index) in props.items"
: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="props.activeTab === 'library'"
:class="[
isMediaSelected(item) ? 'admin-media__item--selected' : '',
props.draggingUrls.includes(item.url) ? 'admin-media__item--dragging opacity-40' : '',
props.disabled ? 'opacity-60' : props.activeTab === 'library' ? 'cursor-grab active:cursor-grabbing' : ''
]"
:draggable="props.activeTab === 'library' && !props.disabled"
@dragstart="emit('drag-start', $event, item)"
@dragend="emit('drag-end')"
>
<span v-if="isMediaSelected(item)" class="admin-media__selection-outline pointer-events-none absolute inset-0 z-20 border-2 border-sky-600" aria-hidden="true" />
<button
type="button"
class="admin-media__select-toggle absolute left-1.5 top-1.5 z-10 grid size-7 place-items-center rounded-md border-2 shadow-md outline-none transition focus-visible:ring-2 focus-visible:ring-[#15171a] focus-visible:ring-offset-1"
@@ -66,6 +82,7 @@ const isMediaSelected = (item) => props.selectedUrls.includes(item?.url)
: 'border-[#394047] bg-white/95 text-[#15171a] hover:border-[#15171a] hover:bg-white'"
:aria-label="`${item.name} 선택`"
:aria-pressed="isMediaSelected(item)"
:disabled="props.disabled"
@click.stop="emit('toggle-selection', item, index, $event)"
>
<svg
@@ -86,6 +103,7 @@ const isMediaSelected = (item) => props.selectedUrls.includes(item?.url)
<button
class="admin-media__thumb flex w-full flex-col text-left outline-none"
type="button"
:disabled="props.disabled"
@click="emit('open-detail', item)"
>
<span class="admin-media__thumb-frame relative block w-full overflow-hidden">
@@ -96,6 +114,7 @@ const isMediaSelected = (item) => props.selectedUrls.includes(item?.url)
:alt="item.title"
loading="lazy"
decoding="async"
:draggable="false"
@error="emit('image-error', $event, item)"
>
<AdminMediaVideoThumbnail

View File

@@ -77,6 +77,7 @@ const seekPreviewFrame = () => {
:src="thumbnailUrl"
:alt="alt"
loading="lazy"
:draggable="false"
>
<span
v-else

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.12.21
- 관리자 미디어의 전체 선택, 다중 드래그, 폴더 드롭 안내와 목록 로딩·재시도를 개선했다.
- CSP 관찰의 목적과 수집 한계를 안내하고 누적 횟수의 의미를 명확히 했다.
## v1.12.11
- CSP 관찰 표에서 빈 차단 출처와 브라우저 확장 프로그램 계열을 더 구분하기 쉽게 정리했다.

View File

@@ -66,6 +66,8 @@ v1.11.0은 `base-uri`, `object-src`, `frame-ancestors`만 강제한다. 광고
배포 후 관리자 대시보드의 `CSP 관찰` 표 또는 `csp_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. `empty`, `about:`, 브라우저 확장 프로그램 scheme은 실제 외부 origin 허용 후보가 아니라 브라우저·확장 프로그램·빈 iframe 계열 신호로 본다. `허용 후보`는 지시어별 외부 origin을 빠르게 묶어 보여주는 보조 정보일 뿐이므로, 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다.
v1.12.21은 `최근 관찰 항목 누적` 표기로 집계 의미를 바로잡는다. 이는 최근 30일 발생 건수가 아니라 최근 보고된 행의 전체 누적값이다. `empty`·`unknown`만으로 iframe 보고의 원인을 확정하지 않는다. 현재 정책은 HTTPS 출처를 폭넓게 허용하므로 후보 부재를 정상 출처 조사 완료로 해석하지 않는다. 이번 변경은 관리자 UI·설명 개선으로 DB 마이그레이션이나 CSP 강제 정책 변경은 없다.
---
## 로컬 개발

View File

@@ -1,3 +1,9 @@
## 2026-09-28 v1.12.21 — 미디어 선택은 폴더 정리와 삭제 권한을 분리한다
기존 현재 페이지 선택은 사용 중인 파일을 제외했지만 논리 폴더 이동은 콘텐츠 URL을 바꾸지 않아 사용 중인 원본도 정리할 수 있다. 전체 선택은 현재 페이지의 모든 항목으로 통일하고 삭제 시에만 기존 사용 중 잠금을 적용한다. 새 폴더 생성 때 선택을 유지하고, 드래그 이동은 선택한 항목 수·대상 폴더·처리 결과를 표시한다. 이동 완료 후 현재 탐색 위치를 유지해 폴더가 갑자기 전환되지 않게 한다. 조회 중 이전 목록을 숨기고 같은 조회 키에서 앞선 요청을 취소해 빠른 폴더 전환에서 늦은 응답이 화면을 되돌리지 않게 한다.
CSP 관찰은 v1.12.9~11에서 강제 정책 확대 전 호환성 확인을 위해 도입했다. 이번 제공 화면의 상위 8행은 모두 `frame-src`의 `empty` 또는 `unknown`이어서 외부 허용 도메인이나 원인을 확정할 근거가 아니다. 코드의 `recentViolations`는 최근 30일 발생 수가 아니라 `last_seen_at`이 기간 안에 있는 행의 전체 누적 합계다. 일별 원장이 없으므로 과거 기간별 발생 수를 복원할 수 없다. 또한 현재 `compat-v1`은 HTTPS 출처를 폭넓게 허용해 HTTPS 도메인별 허용 후보를 충분히 수집하지 못한다. 강제 정책을 그대로 유지하고 대시보드 수치·설명을 바로잡으며, 관찰 정책 세분화와 일별 집계는 후속 과제로 기록한다. 운영 DB 전체를 직접 분석한 결과가 아니라 제공 화면·저장소 코드·기존 이력에 근거한 판단이다.
## 2026-08-13 v1.12.20 — NAS Compose 편집기는 boolean을 리터럴로 받는다
UGREEN Docker 앱의 Compose 편집기는 변수 치환 전 YAML 스키마를 검사하므로 `external: ${POSTGRES_VOLUME_EXTERNAL:-true}`를 boolean이 아니라 문자열로 판단한다. 운영 DB 볼륨은 이미 외부 고정 볼륨으로 존재해야 하는 구조이므로 `external: true`를 리터럴로 고정해 편집기 경고를 없애고, 신규 설치는 배포 전 볼륨을 먼저 생성하는 절차로 안내한다.

View File

@@ -211,6 +211,7 @@
| tests/sharp-image-processing.test.js | Sharp 기반 게시물 카드·본문·페이지 커버 WebP 파생 이미지 생성 스모크 테스트 |
| tests/markdown-table.test.js | Markdown 표 행·정렬·블록 파싱 단위 회귀 테스트 |
| tests/admin-pagination.test.js | 관리자 페이지네이션 페이지 보정·ellipsis 항목 단위 테스트 |
| tests/playwright/e2e/admin-media-interactions.spec.js | 관리자 미디어 전체 선택·선택 유지 폴더 생성·다중 드래그/드롭 강조·이동 진행·취소/실패·목록 지연/최신 요청/재시도 E2E 회귀 검증 |
| tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·CSP·미디어 reconcile/fallback·세션 무효화 API 통합 10건 |
| tests/playwright/e2e/admin-guard-and-settings.spec.js | 설정 카드 독립 저장과 미저장 이탈 취소 브라우저 E2E 2건 |
| tests/playwright/e2e/admin-publish.spec.js | 관리자 로그인·게시물 발행·공개 화면 확인 브라우저 E2E 1건 |
@@ -295,8 +296,8 @@
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaFolderPanel.vue | 관리자 미디어 화면의 폴더 사이드바(라이브러리 폴더 트리·폴더별 개수·드래그 드롭 이동·폴더 삭제 메뉴·폴더 추가 버튼·시스템 이미지 탭 안내 패널) |
| components/admin/AdminMediaGrid.vue | 관리자 미디어 화면의 그리드 카드 목록(미디어 선택 체크·이미지/영상/파일 썸네일·사용처 개수·상태 배지·드래그 시작·상세 열기 이벤트) |
| components/admin/AdminMediaFolderPanel.vue | 관리자 미디어 화면의 폴더 사이드바(라이브러리 폴더 트리·폴더별 개수·드롭 대상 강조·이동 개수 안내·외부 드롭 무시·폴더 삭제 메뉴·폴더 추가 버튼·시스템 이미지 탭 안내 패널) |
| components/admin/AdminMediaGrid.vue | 관리자 미디어 화면의 그리드 카드 목록(미디어 선택 체크·선택 테두리·드래그 대상 흐림·이미지/영상/파일 썸네일·사용처 개수·상태 배지·드래그 시작/종료·작업 중 조작 제한·상세 열기 이벤트) |
| components/admin/AdminMediaDetailModal.vue | 관리자 미디어 화면의 상세 모달(미리보기·파일 정보·썸네일 상태·폴더·사용 현황·파일명·삭제 액션 표시) |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·자동 저장 실패/진행 중 이탈 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 설정 패널 상태와 이벤트 연결, 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
@@ -365,7 +366,7 @@
| 파일 | 화면 |
|------|------|
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), IP·줄바꿈된 열린 페이지 수·열린 페이지 목록 포함 접속자 목록) |
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), IP·줄바꿈된 열린 페이지 수·열린 페이지 목록 포함 접속자 목록, CSP 관찰 목적·집계 의미·정책 한계 안내와 상위 보고) |
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, `F`/`ㄹ` 검색창 포커스, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 백링크 개수 열과 고정 크기 참조 게시물 목록 모달·내부 검색·이동 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(고정 전용 API·추천 전용 API·삭제 전 참조 경고·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·첫 저장 준비 오버레이·첫 생성 중 추가 입력 PUT 반영·이탈 직전 플러시, 저장 토스트 |
@@ -375,7 +376,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 | 미디어 관리, **미디어 라이브러리/대표 이미지/본문 이미지/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 라이브러리 파일 종류 필터와 미사용 토글, 시스템 탭 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, `/admin/api/media?paged=1` 기반 50·100·200개 단위 페이지네이션과 현재 페이지 선택·선택 삭제, 표시 개수 셀렉트 SVG chevron 화살표, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·대표 이미지·본문 이미지 역할 배지(파생 파일 자체와 파생 탭에서는 숨김), 그리드용 작은 파생 이미지 우선 표시와 원본 fallback, 대표 이미지 원본 파생 파일 생성·재생성, 본문 이미지 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/대표 이미지/본문 이미지/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 라이브러리 파일 종류 필터와 미사용 토글, 시스템 탭 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, `/admin/api/media?paged=1` 기반 50·100·200개 단위 페이지네이션과 현재 페이지 전체 선택·선택 삭제, 표시 개수 셀렉트 SVG chevron 화살표, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·대표 이미지·본문 이미지 역할 배지(파생 파일 자체와 파생 탭에서는 숨김), 그리드용 작은 파생 이미지 우선 표시와 원본 fallback, 대표 이미지 원본 파생 파일 생성·재생성, 본문 이미지 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 선택 유지 폴더 생성·다중 드래그 개수 미리보기·이동 진행/결과·목록 로딩/오류/재시도·최신 요청 우선 처리·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 메인 네비게이션/페이지 섹션/하단 네비게이션/추천 사이트 탭, 각 탭 작업줄 왼쪽 추가·오른쪽 저장 버튼, 메인·하단 메뉴 추가 모달, 페이지 섹션 추가 모달·수정·삭제·섹션 드래그 정렬·섹션별 카드형 페이지 순서 변경 모달(추천 우선 표시·섹션에서 제거 포함), 추천 사이트는 공개와 같은 카드 목록+편집 모달(전체 URL·썸네일·파비콘 갱신), 행 more vert(삭제), 저장 대상별 버튼(`네비게이션 저장`/`페이지 섹션 저장`), `useAdminToast` |
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |

View File

@@ -68,7 +68,7 @@
- 호환 정책은 모든 경로에서 `Content-Security-Policy-Report-Only`로 유지한다. 사용자 사이트 코드·광고·외부 미디어를 차단하지 않은 채 script/style/img/font/media/frame/connect/worker/manifest 위반을 `/api/csp-report`로 보고하며 `Reporting-Endpoints`와 `Report-To`도 같은 origin 수집 주소를 가리킨다.
- `POST /api/csp-report`는 `application/csp-report`, `application/reports+json`, `application/json`, `application/*+json`만 허용하고 요청 본문은 32KB, Reporting API 배열은 최대 20건으로 제한한다. IP 원문을 보관하지 않는 해시 키 기준으로 분당 60회까지만 받는다.
- CSP 위반은 정책 버전, 쿼리·fragment를 제거한 문서 경로, 지시어, 경로·쿼리를 제거한 차단 origin, 브라우저 계열만 `csp_violation_aggregates`에 누적한다. 원문 URL과 User-Agent는 저장하지 않으며 저장 실패가 일반 응답을 실패시키지 않는다. 차단 출처가 비어 있으면 `empty`, `about:blank` 계열은 `about:`, 브라우저 확장 프로그램은 `chrome-extension:`·`moz-extension:`·`safari-web-extension:` 같은 scheme 단위로 분류한다.
- 관리자 대시보드는 CSP Report-Only 위반 집계가 1건 이상 있을 때만 `CSP 관찰` 표를 표시한다. 표는 최근 30일 기준 상위 위반의 지시어, 차단 출처, 문서 경로, 브라우저 계열, 횟수, 최근 관찰 시각을 보여준다. `script-src-elem`, `style-src-attr`, `child-src` 같은 세부 지시어는 각각 `script-src`, `style-src`, `frame-src` 후보로 묶고, `http(s)` origin만 허용 후보로 별도 표시한다.
- 관리자 대시보드는 CSP Report-Only 위반 집계가 1건 이상 있을 때만 `CSP 관찰` 표를 표시한다. 표는 최근 30일 내 보고된 상위 항목의 지시어, 보고된 출처, 문서 경로, 브라우저 계열, 누적 횟수, 최근 관찰 시각을 보여준다. 횟수는 최초 수집부터 누적이며 실제 최근 30일 발생 수가 아니다. 관찰 목적과 차단 없이 보고만 수집한다는 설명, `empty`·`unknown`의 의미, HTTPS 출처를 폭넓게 허용하는 현재 정책의 관찰 한계를 안내한다. `script-src-elem`, `style-src-attr`, `child-src` 같은 세부 지시어는 각각 `script-src`, `style-src`, `frame-src` 후보로 묶고, `http(s)` origin만 허용 후보로 별도 표시한다. 후보가 없어도 안전성이나 정책 강화 가능성을 확정하지 않는다.
### 홈 Featured (인덱스)
@@ -685,7 +685,7 @@ components/content/
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타, IP, 열린 페이지 수와 열린 페이지 목록 포함)
- `GET /admin/api/analytics/traffic?days=30` - 기간 내 유입원·디바이스·유입 키워드 통계
- `GET /admin/api/analytics/range-detail?startDay=YYYY-MM-DD&endDay=YYYY-MM-DD&limit=5` - 통계 추이 막대 상세(요약·유입·디바이스·인기 게시물·인기 페이지). 인기 콘텐츠는 조회·읽음/방문자·최근 30일 조회·작성일·스크롤 도달 지표를 포함한다. 최대 31일 범위로 제한한다.
- `GET /admin/api/security/csp-violations?days=30&limit=8` - CSP Report-Only 위반 집계 관찰 요약. `days`는 1~90일, `limit`은 1~20개로 제한하며 전체 누적 횟수, 최근 기간 횟수, 마지막 관찰 시각, 상위 위반 목록, 지시어별 외부 origin 허용 후보를 반환한다.
- `GET /admin/api/security/csp-violations?days=30&limit=8` - CSP Report-Only 위반 집계 관찰 요약. `days`는 1~90일, `limit`은 1~20개로 제한하며 전체 누적 횟수, 최근 관찰 항목의 누적 횟수(`recentViolations`: 기간 내 `last_seen_at`을 가진 행의 `violation_count` 합계), 마지막 관찰 시각, 상위 위반 목록, 지시어별 외부 origin 허용 후보를 반환한다. 후보는 최근 관찰 상위 200행에서 계산한다. 일별 원장이 없어 기간 내 실제 발생 횟수는 반환하지 않는다.
- `GET /admin/api/analytics/posts/:id?period=daily&days=14` - 게시물별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 게시물별 유입·디바이스·키워드)
- `GET /admin/api/analytics/pages/:id?period=daily&days=14` - 페이지별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 체류·스크롤 구간)
- `GET /admin/api/posts` - 글 목록. 기본은 본문 없는 배열 응답이며, `paged=true&page=1&pageSize=50&q=검색어&status=published&tag=slug&featured=featured&sort=newest|oldest` 쿼리를 보내면 서버 필터링된 `{ items, pagination, counts }` 페이지 응답을 반환한다.
@@ -999,8 +999,10 @@ components/content/
- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**대표 이미지**·**본문 이미지**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 대표 이미지 탭에서는 `/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 용도를 구분해 표시한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 대표 이미지 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 역할 배지는 표시하지 않으며, 탭 전환 중 이전 응답 항목이 잠깐 남아도 파생 파일 URL 기준으로 역할 배지를 숨긴다. 미디어 그리드는 서버 페이지 응답 기준 50·100·200개 단위 페이지네이션을 사용하며, 페이지당 표시 개수 셀렉트는 다른 관리자 셀렉트와 같은 SVG chevron 화살표를 사용한다. 원본 이미지 항목도 가능한 경우 720px 본문 파생 이미지 또는 카드·Pages 커버 썸네일을 우선 표시하고 실패 시 원본으로 되돌린다. 파생 이미지 목록·상세 미리보기는 파일 수정 시각과 용량을 캐시 버전으로 붙여 재생성 직후에도 새 파일을 확인할 수 있게 한다.
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 현재 페이지 안의 범위 선택이 가능하다. 현재 페이지 선택은 화면에 보이는 항목 중 사용 중이 아니어서 삭제 가능한 항목만 대상으로 한다.
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 현재 페이지 안의 범위 선택이 가능하다. **전체 선택 / 전체 선택 해제**는 현재 페이지에 표시된 모든 항목에 적용하며 사용 중인 항목도 포함한다. 선택은 카드 테두리와 체크로 표시한다. 선택 삭제는 사용 중인 항목을 제외하고 가능한 개수를 확인받는다.
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 논리 폴더 경로가 일괄 변경된다. 드래그 미리보기와 안내에 이동 개수를 표시하고, 이동 대상 카드는 흐리게 표시하며, 폴더 위에서는 강조와 `놓으면 N개 이동`을 표시한다. 전체 미디어에 드롭하면 `미분류`로 이동한다는 안내를 별도로 표시한다. 이미 대상 폴더에 있는 항목과 시스템 관리 항목은 제외한다. 이동 중에는 중복 이동·선택·삭제를 막고 결과를 토스트로 안내한다. 성공 후 현재 탐색 위치를 유지하고 선택을 해제하며, 실패 시 선택을 유지한다. 취소·종료 시 드래그 상태를 정리하고 외부 파일 드롭으로 기존 선택 항목이 이동하지 않게 한다.
- 선택 항목이 있는 상태에서 폴더를 생성하면 현재 폴더와 선택을 유지해 새 폴더로 바로 드래그할 수 있다. 선택이 없으면 생성한 폴더를 연다.
- 폴더·탭·검색·필터·페이지 전환과 목록 갱신 중에는 이전 그리드·범위 수치·페이지네이션을 숨기고 로딩 안내·스켈레톤을 표시한다. 최신 조회가 앞선 요청을 취소하며, 조회 실패는 빈 폴더와 구분된 오류 및 **다시 시도** 버튼으로 표시한다.
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(프로필 이미지 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
- API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라 `useAdminToast` 우측 상단 토스트로 표시해 모달에 가리지 않는다.

View File

@@ -7,8 +7,10 @@
## 프론트엔드 개발
- CSP Report-Only 운영 보고 관찰 후 강제 정책 추가 강화 검토.
- 광고·사용자 사이트 코드·외부 미디어의 정상 출처를 관리자 대시보드 `CSP 관찰` 표의 허용 후보와 `csp_violation_aggregates`에서 충분히 확인.
- 후보 origin 중 정상 출처와 반복 오탐을 정리한 뒤 script/style/img/frame/connect 지시어를 단계별 강제 전환.
- 현재 `compat-v1`은 HTTPS 출처를 폭넓게 허용하므로 단순 관찰 연장만으로 HTTPS 도메인별 허용 목록을 만들 수 없음. 광고·사이트 코드·외부 미디어 정상 출처를 조사한 뒤 별도 버전의 더 좁은 Report-Only 정책을 설계.
- 제공 화면의 `frame-src empty/unknown`은 출처 정보 부족으로 원인 미확정. 해당 게시물에서 확장 프로그램을 끈 브라우저로 재현하고 iframe·광고 동작과 보고를 대조한 뒤 오탐 여부 판단.
- `recentViolations`는 최근에 관찰된 행의 전체 누적 합계임. 실제 30일 추이가 필요하면 일별 집계·보관 기간·새 관찰 시작점을 설계하며 기존 누적값을 기간별 수치로 변환하지 않음.
- 정상 출처와 반복 오탐 확인 후 script/style/img/frame/connect 지시어의 단계별 강제 전환 검토.
## 콘텐츠 스타일 구현

View File

@@ -1,5 +1,16 @@
# 업데이트 이력
## v1.12.21
- 관리자 미디어 다중 선택 테두리·이동 개수 드래그 미리보기·폴더 드롭 강조와 안내 추가.
- 폴더 이동 중·완료·실패 피드백, 드래그 취소 정리와 외부 드롭 무시 처리 추가.
- 폴더·탭·필터·페이지 전환 시 로딩 표시, 이전 목록 숨김, 조회 실패 재시도 추가.
- 현재 페이지 전체 선택에 사용 중인 항목 포함, 선택 상태를 유지한 새 폴더 생성 지원 추가.
- CSP 관찰 목적·HTTPS 출처 관찰 한계 안내 및 최근 관찰 항목의 누적 횟수 표기 수정.
- 관리자 미디어 다중 드래그·취소·이동 실패·조회 지연·재시도·전체 선택 E2E 회귀 검증 추가.
- 기존 E2E 로그인 버튼 선택자를 현재 화면에 맞추고 초기 로드 대기 보강.
- JavaScript 문법 검사·단위 테스트 273건·격리 DB의 Chromium E2E 10건·프로덕션 빌드 검증 진행.
## v1.12.20
- UGREEN Docker 앱 Compose 편집기의 boolean 스키마 검사에 맞춰 PostgreSQL 외부 볼륨 `external` 값을 리터럴 `true`로 고정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.12.20",
"version": "1.12.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.12.20",
"version": "1.12.21",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.12.20",
"version": "1.12.21",
"private": true,
"type": "module",
"imports": {

View File

@@ -788,12 +788,12 @@ watch(trendRows, () => {
CSP 관찰
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
최근 {{ cspViolationSummary.days }}일 Report-Only 위반 상위 항목
최근 {{ cspViolationSummary.days }}일 내 보고된 항목 · 횟수는 최초 수집부터 누적
</p>
</div>
<div class="admin-dashboard__csp-summary text-right text-xs text-muted">
<p>
최근 {{ formatNumber(cspViolationSummary.recentViolations) }}회
최근 관찰 항목 누적 {{ formatNumber(cspViolationSummary.recentViolations) }}회
</p>
<p class="mt-1">
전체 {{ formatNumber(cspViolationSummary.totalViolations) }}회 · 마지막 {{ formatDashboardDateTime(cspViolationSummary.latestSeenAt) }}
@@ -801,6 +801,19 @@ watch(trendRows, () => {
</div>
</div>
<div class="admin-dashboard__csp-explanation mt-4 rounded border border-line bg-paper p-3 text-xs leading-relaxed text-muted">
<p class="font-semibold text-ink">보안 정책을 강화하기 전, 사이트 기능과 충돌하는지 확인하는 관찰 기록입니다.</p>
<p class="mt-1">Report-Only는 차단 없이 보고만 수집합니다. 이 횟수가 공격 수나 실제 차단 수를 뜻하지는 않습니다.</p>
<details class="admin-dashboard__csp-help mt-2">
<summary class="cursor-pointer font-semibold text-ink">기록 해석과 관찰의 한계</summary>
<ul class="mt-2 list-disc space-y-1 pl-4">
<li>frame-src는 iframe 같은 삽입 화면에 관한 규칙입니다. empty는 보고에 출처가 없고, unknown은 출처를 식별하지 못했다는 뜻입니다. 이 값만으로 광고·확장 프로그램 등 원인을 확정할 수 없습니다.</li>
<li>현재 관찰 정책은 HTTPS 출처를 폭넓게 허용합니다. 보고가 없거나 허용 후보가 비어 있어도 모든 외부 출처를 확인한 것은 아닙니다.</li>
<li>최근 관찰 항목 누적은 최근 {{ cspViolationSummary.days }}일에 보고된 항목들의 전체 누적 합계입니다. 해당 기간에만 발생한 횟수는 현재 집계로 알 수 없습니다.</li>
</ul>
</details>
</div>
<div
v-if="cspAllowlistCandidates.length"
class="admin-dashboard__csp-candidates mt-4 border border-line bg-paper p-3"
@@ -820,6 +833,9 @@ watch(trendRows, () => {
</span>
</div>
</div>
<p v-else class="admin-dashboard__csp-no-candidates mt-3 text-xs text-muted">
조회한 상위 보고에서 외부 출처 허용 후보를 찾지 못했습니다. 이 결과만으로 차단 정책을 강화하지 않습니다.
</p>
<div
v-if="cspViolations.length"
@@ -832,7 +848,7 @@ watch(trendRows, () => {
지시어
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
차단 출처
보고된 출처
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
문서
@@ -841,7 +857,7 @@ watch(trendRows, () => {
브라우저
</th>
<th class="whitespace-nowrap px-2 py-2 text-right font-semibold">
횟수
누적 횟수
</th>
<th class="whitespace-nowrap px-2 py-2 text-right font-semibold">
최근
@@ -858,7 +874,7 @@ watch(trendRows, () => {
{{ row.effectiveDirective }}
</td>
<td class="max-w-[260px] truncate px-2 py-2 text-muted">
{{ row.blockedOrigin }}
{{ row.blockedOrigin === 'empty' ? '출처 없음 (empty)' : row.blockedOrigin === 'unknown' ? '식별 불가 (unknown)' : row.blockedOrigin }}
</td>
<td class="max-w-[240px] truncate px-2 py-2 text-muted">
{{ row.documentPath }}
@@ -880,7 +896,7 @@ watch(trendRows, () => {
v-else
class="admin-dashboard__csp-empty mt-4 border border-line bg-paper px-3 py-2 text-xs text-muted"
>
최근 {{ cspViolationSummary.days }}일에 집계된 CSP 위반은 없습니다.
최근 {{ cspViolationSummary.days }}일에 보고된 CSP 관찰 항목은 없습니다.
</p>
</section>

View File

@@ -61,6 +61,8 @@ const mediaPageSize = ref(50)
/** 그리드 파생 이미지 실패 후 원본으로 되돌린 항목 URL */
const mediaGridPreviewFallbackUrls = ref(new Set())
const draggingUrls = ref([])
const movingMedia = ref(null)
let mediaDragPreview = null
const uploadInputRef = ref(null)
const uploadingFiles = ref(false)
const deletingSelected = ref(false)
@@ -85,8 +87,11 @@ const mediaQuery = computed(() => ({
unused: showUnusedOnly.value ? '1' : undefined
}))
const { data: mediaResponse, refresh } = await useFetch('/admin/api/media', {
const { data: mediaResponse, status: mediaStatus, error: mediaError, refresh } = await useFetch('/admin/api/media', {
key: 'admin-media-library',
query: mediaQuery,
watch: false,
dedupe: 'cancel',
default: () => ({
items: [],
pagination: {
@@ -121,6 +126,9 @@ const { data: mediaResponse, refresh } = await useFetch('/admin/api/media', {
}
})
const isMediaLoading = computed(() => mediaStatus.value === 'pending')
const isMediaInteractionDisabled = computed(() => isMediaLoading.value || Boolean(mediaError.value) || Boolean(movingMedia.value) || deletingSelected.value)
const {
canRegeneratePageCoverThumbnail,
canRegeneratePostBodyThumbnail,
@@ -303,7 +311,7 @@ const onMediaGridImageError = (_event, item) => {
}
/**
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
* 파일명 변경·삭제가 제한되는지 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 잠금이면 true
*/
@@ -601,7 +609,7 @@ const mediaPageEndIndex = computed(() => Math.min(mediaPageStartIndex.value + fi
const paginatedMediaItems = computed(() => filteredMediaItems.value)
const filteredSelectableMediaItems = computed(() => paginatedMediaItems.value.filter((item) => !isMediaItemLocked(item)))
const filteredSelectableMediaItems = computed(() => paginatedMediaItems.value)
const filteredSelectableMediaUrls = computed(() => filteredSelectableMediaItems.value.map((item) => item.url))
@@ -670,6 +678,10 @@ const selectFolder = (folder) => {
* @returns {void}
*/
const toggleMediaSelection = (item, index, event) => {
if (isMediaInteractionDisabled.value) {
return
}
if (event.shiftKey && lastSelectedIndex.value >= 0) {
const startIndex = Math.min(lastSelectedIndex.value, index)
const endIndex = Math.max(lastSelectedIndex.value, index)
@@ -700,7 +712,7 @@ const clearMediaSelection = () => {
* @returns {void}
*/
const toggleFilteredMediaSelection = () => {
if (!filteredSelectableMediaUrls.value.length) {
if (isMediaInteractionDisabled.value || !filteredSelectableMediaUrls.value.length) {
return
}
@@ -874,8 +886,11 @@ const submitCreateFolderModal = async () => {
})
closeCreateFolderModal()
activeFolder.value = createdFolder.path
if (!selectedMediaUrls.value.length) {
selectFolder(createdFolder.path)
}
await refreshMediaFolders()
showToast('success', `「${createdFolder.path}」 폴더를 만들었습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '폴더를 만들지 못했습니다.')
}
@@ -929,35 +944,54 @@ const removeMediaFolder = async (folder) => {
* @returns {Promise<void>}
*/
const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
if (activeTab.value !== 'library') {
if (activeTab.value !== 'library' || isMediaInteractionDisabled.value) {
return
}
const targetUrls = [...new Set(urls.filter(Boolean))]
const targetFolder = folder || '미분류'
const targetUrls = [...new Set(urls)].filter((url) => {
const item = mediaItems.value.find((mediaItem) => mediaItem.url === url)
return item && canEditMediaCategory(item) && item.category !== targetFolder
})
if (!targetUrls.length) {
showToast('info', '이 폴더로 이동할 항목이 없습니다.')
return
}
movingMedia.value = { count: targetUrls.length, folder: targetFolder }
try {
await $fetch('/admin/api/media', {
method: 'PUT',
body: {
urls: targetUrls,
category: folder || '미분류'
category: targetFolder
}
})
await Promise.all([
refresh(),
refreshMediaFolders()
])
activeFolder.value = folder || '미분류'
clearMediaSelection()
showToast('success', `${targetUrls.length}개 미디어를 「${targetFolder}」 폴더로 이동했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '미디어 폴더를 변경하지 못했습니다.')
} finally {
movingMedia.value = null
}
}
/**
* 드래그 종료·취소 시 이동 대상과 미리보기를 정리한다.
* @returns {void}
*/
const endMediaDrag = () => {
draggingUrls.value = []
mediaDragPreview?.remove()
mediaDragPreview = null
}
/**
* 미디어 드래그 시작
* @param {DragEvent} event - 드래그 이벤트
@@ -965,7 +999,10 @@ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
* @returns {void}
*/
const startMediaDrag = (event, item) => {
if (activeTab.value !== 'library') {
endMediaDrag()
if (activeTab.value !== 'library' || isMediaInteractionDisabled.value || !canEditMediaCategory(item)) {
event.preventDefault()
return
}
@@ -973,11 +1010,20 @@ const startMediaDrag = (event, item) => {
selectedMediaUrls.value = [item.url]
}
draggingUrls.value = [...selectedMediaUrls.value]
draggingUrls.value = mediaItems.value
.filter((mediaItem) => isMediaSelected(mediaItem) && canEditMediaCategory(mediaItem))
.map((mediaItem) => mediaItem.url)
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('application/x-sori-media', JSON.stringify(draggingUrls.value))
event.dataTransfer.setData('text/plain', draggingUrls.value.join('\n'))
mediaDragPreview = document.createElement('div')
mediaDragPreview.className = 'admin-media__drag-preview fixed -left-[10000px] top-0 rounded-lg border-2 border-white bg-sky-700 px-5 py-3 text-sm font-semibold text-white shadow-xl'
mediaDragPreview.textContent = `미디어 ${draggingUrls.value.length}개 이동`
mediaDragPreview.setAttribute('aria-hidden', 'true')
document.body.appendChild(mediaDragPreview)
event.dataTransfer.setDragImage(mediaDragPreview, 24, 24)
}
}
@@ -987,13 +1033,11 @@ const startMediaDrag = (event, item) => {
* @returns {Promise<void>}
*/
const dropMediaOnFolder = async (folder) => {
if (activeTab.value !== 'library') {
draggingUrls.value = []
const urls = [...draggingUrls.value]
endMediaDrag()
if (!urls.length) {
return
}
const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value
draggingUrls.value = []
await moveMediaToFolder(folder, urls)
}
@@ -1142,6 +1186,7 @@ watch(filteredMediaUrls, (urls) => {
watch([activeTab, activeFolder, activeMediaKind, showUnusedOnly, searchText], () => {
mediaPage.value = 1
endMediaDrag()
clearMediaSelection()
closeMediaDetail()
})
@@ -1161,6 +1206,11 @@ watch(() => mediaPagination.value.page, (page) => {
mediaPage.value = normalizedPage
}
})
/** 필터·페이지 초기화를 반영한 최종 조건으로 조회하고 진행 중인 이전 요청을 취소한다. */
watch(mediaQuery, () => refresh(), { flush: 'post' })
onBeforeUnmount(endMediaDrag)
</script>
<template>
@@ -1267,6 +1317,8 @@ watch(() => mediaPagination.value.page, (page) => {
:active-system-media-hint="activeSystemMediaHint"
:active-system-media-title="activeSystemMediaTitle"
:deleting-folder="deletingFolder"
:dragging-count="draggingUrls.length"
:busy="Boolean(movingMedia) || deletingSelected"
:folder-media-counts="folderMediaCounts"
:folders="normalizedFolders"
:library-count="mediaCounts.tabs?.library || 0"
@@ -1277,18 +1329,18 @@ watch(() => mediaPagination.value.page, (page) => {
@select-folder="selectFolder"
/>
<div class="admin-media__content min-w-0">
<div class="admin-media__content min-w-0" :aria-busy="isMediaLoading || Boolean(movingMedia)">
<div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="admin-media__folder-title text-lg font-semibold text-ink">
{{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }}
</h2>
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
{{ mediaPageTotal }}개 중 {{ mediaPageDisplayStart }}-{{ mediaPageEndIndex }}개 표시
{{ isMediaLoading ? '미디어를 불러오는 중…' : mediaError ? '목록을 불러오지 못했습니다.' : `${mediaPageTotal}개 중 ${mediaPageDisplayStart}-${mediaPageEndIndex}개 표시` }}
</p>
</div>
<div class="admin-media__selection-actions flex flex-wrap items-center justify-end gap-2">
<div v-if="filteredMediaItems.length" class="admin-media__page-size flex items-center gap-2 text-xs font-semibold text-muted">
<div class="admin-media__page-size flex items-center gap-2 text-xs font-semibold text-muted">
<span>표시</span>
<AdminSelectInput
:model-value="mediaPageSize"
@@ -1300,34 +1352,68 @@ watch(() => mediaPagination.value.page, (page) => {
/>
</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"
type="button"
title="현재 페이지에 표시된 미디어를 모두 선택합니다."
:aria-pressed="isAllFilteredMediaSelected"
:disabled="!filteredSelectableMediaUrls.length"
:disabled="isMediaInteractionDisabled || !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>
<button
class="admin-media__selection-delete font-semibold text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="deletingSelected"
:disabled="isMediaInteractionDisabled || !selectedDeletableMediaItems.length"
@click="deleteSelectedMedia"
>
{{ deletingSelected ? '삭제 중' : '선택 삭제' }}
</button>
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink disabled:opacity-50" type="button" :disabled="isMediaInteractionDisabled" @click="clearMediaSelection">
선택 해제
</button>
</div>
</div>
</div>
<p class="admin-media__selection-hint mt-3 text-xs text-muted">
전체 선택은 현재 페이지에 적용됩니다.{{ activeTab === 'library' ? ' 선택한 항목을 왼쪽 폴더에 놓으면 함께 이동합니다.' : '' }} 사용 중인 항목은 삭제에서 제외됩니다.
</p>
<p
v-if="movingMedia || draggingUrls.length"
class="admin-media__move-status mt-3 flex items-center gap-2 rounded border border-sky-200 bg-sky-50 px-3 py-2 text-sm font-semibold text-sky-800"
role="status"
aria-live="polite"
>
<span v-if="movingMedia" class="admin-media__move-spinner size-4 animate-spin rounded-full border-2 border-sky-200 border-t-sky-700 motion-reduce:animate-none" aria-hidden="true" />
{{ movingMedia ? `${movingMedia.count}개 미디어를 「${movingMedia.folder}」 폴더로 이동하는 중…` : `${draggingUrls.length}개 미디어를 이동합니다. 원하는 폴더에 놓으세요.` }}
</p>
<div v-if="isMediaLoading" class="admin-media__loading mt-5" role="status" aria-live="polite">
<p class="admin-media__loading-label flex items-center gap-2 text-sm text-muted">
<span class="admin-media__loading-spinner size-4 animate-spin rounded-full border-2 border-line border-t-sky-700 motion-reduce:animate-none" aria-hidden="true" />
{{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }} 불러오는 중…
</p>
<div class="admin-media__skeleton-grid mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10" aria-hidden="true">
<div v-for="index in 12" :key="index" class="admin-media__skeleton animate-pulse border border-line bg-white motion-reduce:animate-none">
<div class="aspect-square bg-surface" />
<div class="m-2 h-3 w-2/3 rounded bg-surface" />
</div>
</div>
</div>
<div v-else-if="mediaError" class="admin-media__load-error mt-5 rounded border border-red-200 bg-red-50 p-8 text-center" role="alert">
<p class="text-sm text-red-700">미디어를 불러오지 못했습니다. 다시 시도해 주세요.</p>
<button class="admin-media__retry mt-3 rounded border border-red-200 bg-white px-3 py-2 text-xs font-semibold text-red-700" type="button" @click="refresh()">
다시 시도
</button>
</div>
<AdminMediaGrid
v-if="filteredMediaItems.length"
v-else-if="filteredMediaItems.length"
:active-tab="activeTab"
:get-media-badge-class="getMediaBadgeClass"
:get-media-badge-label="getMediaBadgeLabel"
@@ -1336,7 +1422,10 @@ watch(() => mediaPagination.value.page, (page) => {
:get-media-usage-badge-count="getMediaUsageBadgeCount"
:items="paginatedMediaItems"
:selected-urls="selectedMediaUrls"
:dragging-urls="draggingUrls"
:disabled="isMediaInteractionDisabled"
@drag-start="startMediaDrag"
@drag-end="endMediaDrag"
@image-error="onMediaGridImageError"
@open-detail="openMediaDetail"
@toggle-selection="toggleMediaSelection"
@@ -1347,6 +1436,7 @@ watch(() => mediaPagination.value.page, (page) => {
</p>
<AdminPagination
v-if="!isMediaLoading && !mediaError"
:page="mediaPage"
:page-count="mediaPageCount"
label="미디어 페이지"

View File

@@ -10,6 +10,7 @@ const adminPassword = 'playwright-admin-password'
*/
const loginAdmin = async (page) => {
await page.goto('/admin/login')
await page.waitForLoadState('networkidle')
await page.getByLabel('이메일').fill(adminEmail)
await page.locator('input[type="password"]').fill(adminPassword)
@@ -17,7 +18,7 @@ const loginAdmin = async (page) => {
page.waitForResponse((response) =>
response.url().endsWith('/admin/api/auth/login') && response.status() === 200
),
page.getByRole('button', { name: '로그인' }).click()
page.getByRole('button', { name: '로그인', exact: true }).click()
])
await page.waitForURL(/\/admin(?:\/)?$/)
}

View File

@@ -0,0 +1,197 @@
import { expect, test } from '@playwright/test'
/**
* 격리된 테스트 관리자로 로그인하고 지연·실패를 제어할 미디어 응답을 준비한다.
* @param {import('@playwright/test').Page} page - 테스트 페이지
* @param {number} count - 테스트 미디어 개수
* @returns {Promise<Object>} 요청과 응답 제어 상태
*/
const openMediaLibrary = async (page, count = 3) => {
await page.goto('/admin/login')
await page.waitForLoadState('networkidle')
await page.getByLabel('이메일').fill('playwright-admin@example.com')
await page.locator('input[type="password"]').fill('playwright-admin-password')
await page.getByRole('button', { name: '로그인', exact: true }).click()
await page.waitForURL(/\/admin\/?$/)
await page.waitForLoadState('networkidle')
const state = {
folders: ['미분류', '보관', '빈 폴더'],
items: Array.from({ length: count }, (_, index) => ({
url: `/uploads/media-interaction-${index}.webp`,
name: `사진-${index + 1}.webp`,
title: `사진 ${index + 1}`,
category: '미분류',
kind: 'image',
usage: index === 0 ? [{ title: '사용 중인 게시물' }] : []
})),
moves: [],
moveGate: null,
folderGate: null,
failMove: false,
failList: false
}
await page.route('**/uploads/media-interaction-*.webp', (route) => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><rect width="160" height="160" fill="#dbeafe"/><circle cx="80" cy="80" r="40" fill="#0284c7"/></svg>'
}))
await page.route('**/admin/api/media-folders', async (route) => {
if (route.request().method() === 'POST') {
const { path } = route.request().postDataJSON()
state.folders.push(path)
await route.fulfill({ json: { path } })
return
}
await route.fulfill({ json: state.folders })
})
await page.route(/\/admin\/api\/media(?:\?|$)/, async (route) => {
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON()
state.moves.push(body)
await state.moveGate?.promise
if (state.failMove) {
await route.fulfill({ status: 500, json: { message: '이동 실패 테스트' } })
return
}
state.items.forEach((item) => {
if (body.urls.includes(item.url)) item.category = body.category
})
await route.fulfill({ json: [] })
return
}
const query = new URL(route.request().url()).searchParams
const folder = query.get('folder') || ''
const pageNumber = Number(query.get('page') || 1)
const pageSize = Number(query.get('pageSize') || 50)
if (folder === '보관') await state.folderGate?.promise
if (state.failList) {
await route.fulfill({ status: 500, json: { message: '목록 실패 테스트' } })
return
}
const items = state.items.filter((item) => !folder || item.category === folder)
const folders = Object.fromEntries(state.folders.map((path) => [path, state.items.filter((item) => item.category === path).length]))
await route.fulfill({
json: {
items: items.slice((pageNumber - 1) * pageSize, pageNumber * pageSize),
pagination: { page: pageNumber, pageSize, total: items.length, pageCount: Math.max(1, Math.ceil(items.length / pageSize)) },
counts: { tabs: { library: state.items.length }, folders, kinds: { all: items.length, image: items.length }, usage: { all: items.length, unused: items.filter((item) => !item.usage.length).length } }
}
})
})
await page.getByRole('link', { name: '미디어', exact: true }).click()
await expect(page.locator('.admin-media__item')).toHaveCount(Math.min(count, 50))
return state
}
/**
* 실제 마우스로 썸네일 드래그를 시작하고 대상 폴더 위에서 멈춘다.
* @param {import('@playwright/test').Page} page - 테스트 페이지
* @param {string} folder - 대상 폴더
* @returns {Promise<void>}
*/
const dragMediaOverFolder = async (page, folder) => {
const source = await page.locator('.admin-media__image').first().boundingBox()
const target = await page.getByTitle(folder, { exact: true }).boundingBox()
const sourceX = source.x + source.width - 8
const sourceY = source.y + source.height - 8
await page.mouse.move(sourceX, sourceY)
await page.mouse.down()
await page.mouse.move(sourceX - 15, sourceY, { steps: 5 })
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.move(target.x + target.width / 2 + 1, target.y + target.height / 2)
}
test('다중 선택·폴더 생성·드롭 강조·일괄 이동 상태를 표시한다', async ({ page }, testInfo) => {
const state = await openMediaLibrary(page)
await page.getByRole('button', { name: '전체 선택', exact: true }).click()
await expect(page.locator('.admin-media__selection-count')).toHaveText('3개 선택됨')
await expect(page.locator('.admin-media__item--selected')).toHaveCount(3)
await page.getByRole('button', { name: '폴더 추가', exact: true }).click()
await page.getByLabel('폴더 이름').fill('새 정리 폴더')
await page.getByRole('button', { name: '만들기', exact: true }).click()
await expect(page.getByTitle('새 정리 폴더', { exact: true })).toBeVisible()
await expect(page.locator('.admin-media__selection-count')).toHaveText('3개 선택됨')
state.moveGate = Promise.withResolvers()
await dragMediaOverFolder(page, '새 정리 폴더')
await expect(page.locator('.admin-media__folder-row--drop-target')).toContainText('놓으면 3개 이동')
await expect(page.locator('.admin-media__drag-preview')).toHaveText('미디어 3개 이동')
await expect(page.locator('.admin-media__item--dragging')).toHaveCount(3)
await page.screenshot({ path: testInfo.outputPath('media-drag.png'), fullPage: true, animations: 'disabled' })
await page.mouse.up()
await expect(page.locator('.admin-media__move-status')).toContainText('3개 미디어를 「새 정리 폴더」 폴더로 이동하는 중')
await expect(page.locator('.admin-media__select-all')).toBeDisabled()
expect(state.moves[0]).toEqual({ urls: state.items.map((item) => item.url), category: '새 정리 폴더' })
state.moveGate.resolve()
await expect(page.getByText('3개 미디어를 「새 정리 폴더」 폴더로 이동했습니다.', { exact: true })).toBeVisible()
await expect(page.locator('.admin-media__move-status')).toHaveCount(0)
await expect(page.locator('.admin-media__selection-count')).toHaveCount(0)
await page.getByTitle('새 정리 폴더', { exact: true }).click()
await expect(page.locator('.admin-media__item')).toHaveCount(3)
})
test('드래그 취소·외부 드롭을 무시하고 이동 실패 시 선택을 유지한다', async ({ page }) => {
const state = await openMediaLibrary(page)
await page.getByRole('button', { name: '전체 선택', exact: true }).click()
await dragMediaOverFolder(page, '보관')
await page.keyboard.press('Escape')
await page.mouse.up()
await expect(page.locator('.admin-media__folder-row--drop-target')).toHaveCount(0)
await expect(page.locator('.admin-media__drag-preview')).toHaveCount(0)
await expect(page.locator('.admin-media__item--dragging')).toHaveCount(0)
await page.getByTitle('보관', { exact: true }).dispatchEvent('drop')
expect(state.moves).toHaveLength(0)
state.failMove = true
await dragMediaOverFolder(page, '보관')
await page.mouse.up()
await expect(page.getByText('이동 실패 테스트', { exact: true })).toBeVisible()
await expect(page.locator('.admin-media__selection-count')).toHaveText('3개 선택됨')
await expect(page.locator('.admin-media__move-status')).toHaveCount(0)
await expect(page.locator('.admin-media__select-all')).toBeEnabled()
})
test('폴더 전환은 이전 목록을 숨기고 마지막 응답·오류·재시도를 구분한다', async ({ page }, testInfo) => {
const state = await openMediaLibrary(page)
state.folderGate = Promise.withResolvers()
await page.getByTitle('보관', { exact: true }).click()
await expect(page.locator('.admin-media__loading')).toContainText('보관 불러오는 중')
await expect(page.locator('.admin-media__item')).toHaveCount(0)
await expect(page.locator('.admin-media__empty')).toHaveCount(0)
await expect(page.locator('.admin-media__select-all')).toBeDisabled()
await expect(page.getByTitle('미분류', { exact: true })).toBeVisible()
await expect(page.locator('.admin-media__folder-row').filter({ has: page.getByTitle('보관', { exact: true }) })).toHaveCSS('background-color', 'rgb(21, 23, 26)')
await page.screenshot({ path: testInfo.outputPath('media-loading.png'), fullPage: true, animations: 'disabled' })
await page.getByTitle('미분류', { exact: true }).click()
await expect(page.locator('.admin-media__item')).toHaveCount(3)
state.folderGate.resolve()
await expect(page.locator('.admin-media__folder-title')).toHaveText('미분류')
await expect(page.locator('.admin-media__item')).toHaveCount(3)
state.failList = true
await page.getByTitle('빈 폴더', { exact: true }).click()
await expect(page.locator('.admin-media__load-error')).toBeVisible()
await expect(page.locator('.admin-media__empty')).toHaveCount(0)
state.failList = false
await page.getByRole('button', { name: '다시 시도', exact: true }).click()
await expect(page.locator('.admin-media__empty')).toBeVisible()
await expect(page.locator('.admin-media__load-error')).toHaveCount(0)
})
test('전체 선택은 현재 페이지에만 적용하고 사용 중인 파일도 포함한다', async ({ page }) => {
await openMediaLibrary(page, 55)
await page.getByRole('button', { name: '전체 선택', exact: true }).click()
await expect(page.locator('.admin-media__selection-count')).toHaveText('50개 선택됨')
await expect(page.locator('.admin-media__select-toggle[aria-pressed="true"]')).toHaveCount(50)
await page.getByRole('button', { name: '전체 선택 해제', exact: true }).click()
await expect(page.locator('.admin-media__selection-count')).toHaveCount(0)
await page.getByRole('button', { name: '다음 페이지', exact: true }).click()
await expect(page.locator('.admin-media__item')).toHaveCount(5)
await page.getByRole('button', { name: '전체 선택', exact: true }).click()
await expect(page.locator('.admin-media__selection-count')).toHaveText('5개 선택됨')
})

View File

@@ -5,6 +5,7 @@ test('관리자가 로그인해 게시물을 발행하고 공개 화면에서
const slug = 'playwright-e2e-post'
await page.goto('/admin/login')
await page.waitForLoadState('networkidle')
await page.getByLabel('이메일').fill('playwright-admin@example.com')
await page.locator('input[type="password"]').fill('playwright-admin-password')
@@ -12,7 +13,7 @@ test('관리자가 로그인해 게시물을 발행하고 공개 화면에서
page.waitForResponse((response) =>
response.url().endsWith('/admin/api/auth/login') && response.status() === 200
),
page.getByRole('button', { name: '로그인' }).click()
page.getByRole('button', { name: '로그인', exact: true }).click()
])
await page.waitForURL(/\/admin(?:\/)?$/)

View File

@@ -10,6 +10,7 @@ const adminPassword = 'playwright-admin-password'
*/
const loginAdmin = async (page) => {
await page.goto('/admin/login')
await page.waitForLoadState('networkidle')
await page.getByLabel('이메일').fill(adminEmail)
await page.locator('input[type="password"]').fill(adminPassword)
@@ -17,7 +18,7 @@ const loginAdmin = async (page) => {
page.waitForResponse((response) =>
response.url().endsWith('/admin/api/auth/login') && response.status() === 200
),
page.getByRole('button', { name: '로그인' }).click()
page.getByRole('button', { name: '로그인', exact: true }).click()
])
await page.waitForURL(/\/admin(?:\/)?$/)
}