미디어 선택 삭제와 모바일 목차 정리 v1.5.14

This commit is contained in:
2026-05-27 15:43:27 +09:00
parent cb92b32f9c
commit ac57ff458d
12 changed files with 266 additions and 24 deletions

View File

@@ -233,7 +233,7 @@ const showAboutSection = false
<div <div
v-if="isPostDetailRoute" v-if="isPostDetailRoute"
class="right-sidebar__block right-sidebar__toc site-sidebar-section py-5 pl-5 pr-0" class="right-sidebar__block right-sidebar__toc site-sidebar-section py-5 pl-5 pr-0 max-lg:hidden"
> >
<div class="right-sidebar__row flex items-center justify-between"> <div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted"> <p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">

View File

@@ -223,7 +223,17 @@ onBeforeUnmount(() => {
class="h-full w-full rounded-full object-cover" class="h-full w-full rounded-full object-cover"
> >
<span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold"> <span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold">
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }} <svg
v-if="member"
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-[var(--site-text)]"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="M367-527q-47-47-47-113t47-113q47-47 113-47t113 47q47 47 47 113t-47 113q-47 47-113 47t-113-47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm296.5-343.5Q560-607 560-640t-23.5-56.5Q513-720 480-720t-56.5 23.5Q400-673 400-640t23.5 56.5Q447-560 480-560t56.5-23.5ZM480-640Zm0 400Z" />
</svg>
<span v-else>?</span>
</span> </span>
</button> </button>
@@ -254,7 +264,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<div class="max-w-xs truncate leading-[1.15]"> <div class="max-w-xs truncate leading-[1.15]">
{{ member?.username || 'Anonymous' }} {{ member?.username || 'Guest' }}
</div> </div>
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted"> <div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
{{ member.email }} {{ member.email }}

View File

@@ -1,5 +1,12 @@
# 업데이트 요약 # 업데이트 요약
## v1.5.14
- 모바일 게시글 화면에서는 하단으로 내려간 오른쪽 사이드바의 TOC를 숨겼다.
- 로그인 회원의 기본 아바타를 사람 아이콘으로 바꿨다.
- 미디어 라이브러리에서 파일을 직접 추가하고, 현재 검색·필터 결과를 전체 선택하거나 선택 삭제할 수 있게 했다.
- 미디어 검색창을 글·멤버 검색창과 같은 스타일로 맞췄다.
## v1.5.13 ## v1.5.13
- 게시글 목차 클릭 이동을 부드러운 스크롤로 바꿨다. - 게시글 목차 클릭 이동을 부드러운 스크롤로 바꿨다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-27 v1.5.14 — 모바일 TOC와 미디어 일괄 작업 정리
게시글 TOC는 데스크톱 오른쪽 사이드바에서는 읽기 이동성을 높이지만, 모바일에서는 사이드바가 본문 아래로 내려가 읽기를 마친 뒤에야 보이므로 실효성이 낮다. 따라서 모바일 폭에서는 TOC를 숨기고 본문 흐름을 짧게 유지한다. 미디어 라이브러리는 폴더·검색·필터가 이미 운영 단위이므로, 전체 선택도 현재 표시 결과만 대상으로 두어 실수로 숨겨진 항목까지 선택하지 않게 했다. 선택 삭제는 기존 잠금 정책을 유지해 게시물·페이지나 회원 프로필에서 쓰이는 파일은 제외한다.
## 2026-05-27 v1.5.13 — TOC 이동감과 위치 보정 ## 2026-05-27 v1.5.13 — TOC 이동감과 위치 보정
목차 링크를 기본 해시 이동에 맡기면 브라우저가 즉시 점프하고, 고정 헤더와 제목의 위치 관계에 따라 제목이 화면 위쪽에 붙거나 일부 가려질 수 있다. 게시글 TOC는 읽는 흐름을 돕는 장치이므로 클릭 이벤트를 직접 처리해 부드러운 스크롤로 이동하고, 제목 요소 자체에는 상단 헤더 높이와 여백을 반영한 scroll margin을 둔다. 목차 링크를 기본 해시 이동에 맡기면 브라우저가 즉시 점프하고, 고정 헤더와 제목의 위치 관계에 따라 제목이 화면 위쪽에 붙거나 일부 가려질 수 있다. 게시글 TOC는 읽는 흐름을 돕는 장치이므로 클릭 이벤트를 직접 처리해 부드러운 스크롤로 이동하고, 제목 요소 자체에는 상단 헤더 높이와 여백을 반영한 scroll margin을 둔다.

View File

@@ -62,11 +62,11 @@
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) | | components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
| components/site/SiteTopChrome.vue | 공개 레이아웃 상단 고정 영역(어나운스 바+헤더), `--site-top-chrome-height` CSS 변수 | | components/site/SiteTopChrome.vue | 공개 레이아웃 상단 고정 영역(어나운스 바+헤더), `--site-top-chrome-height` CSS 변수 |
| components/site/SiteAnnouncementBar.vue | 공개 사이트 상단 어나운스 배너(문구·선택 링크·배경색·닫기) | | components/site/SiteAnnouncementBar.vue | 공개 사이트 상단 어나운스 배너(문구·선택 링크·배경색·닫기) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` | | components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운(로그인 회원 아바타 없으면 사람 아이콘), `/`·`SiteSearchModal` |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 | | components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 | | components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 | | components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation``recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 Recommended 대신 H1~H3 TOC, Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 | | components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation``recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended 대신 H1~H3 TOC(모바일 숨김), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 | | components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 | | components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 | | components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
@@ -133,7 +133,7 @@
| pages/admin/pages/index.vue | 페이지 목록, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) | | pages/admin/pages/index.vue | 페이지 목록, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 | | pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 | | pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/프로필 이미지** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 현재 필터 결과 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 추천 사이트 대체 텍스트·썸네일 URL, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 추천 사이트 대체 텍스트·썸네일 URL, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) | | components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 | | composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |

View File

@@ -26,7 +26,7 @@
| Left Aside(모바일) | `fixed` 좌측 패널, 열림 시 `translate-x-0`, 닫힘 시 화면 밖으로 이동. 열린 동안 백드롭과 `html.site-mobile-nav-open`으로 문서 스크롤 잠금 | | Left Aside(모바일) | `fixed` 좌측 패널, 열림 시 `translate-x-0`, 닫힘 시 화면 밖으로 이동. 열린 동안 백드롭과 `html.site-mobile-nav-open`으로 문서 스크롤 잠금 |
| Main | 중앙 열 안에서 `max-width: 720px`·`justify-self: start`, 별도 `overflow-y` 없음. 공개 페이지의 가로 패딩은 레이아웃 그리드(`public-layout__grid`)의 `px-*` 한 번만 사용하고, 본문 섹션의 `px-*`는 두지 않는다. | | Main | 중앙 열 안에서 `max-width: 720px`·`justify-self: start`, 별도 `overflow-y` 없음. 공개 페이지의 가로 패딩은 레이아웃 그리드(`public-layout__grid`)의 `px-*` 한 번만 사용하고, 본문 섹션의 `px-*`는 두지 않는다. |
| Right Aside | Left와 동일 패턴(스티키·고정 높이·내부 무스크롤바 스크롤·하단 카피라이트) | | Right Aside | Left와 동일 패턴(스티키·고정 높이·내부 무스크롤바 스크롤·하단 카피라이트) |
| Right Aside(모바일) | 본문 아래에 전체 너비로 배치, 상단 보더로 본문과 구분, 좌우 안전 여백(`px-4`) 적용 | | Right Aside(모바일) | 본문 아래에 전체 너비로 배치, 상단 보더로 본문과 구분 |
### 메뉴 토글 ### 메뉴 토글
@@ -37,7 +37,7 @@
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다. - `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다. - `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다. - `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Anonymous·Sign up·Sign in 메뉴를 표시한다. - 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Guest·Sign up·Sign in 메뉴를 표시한다. 로그인 회원에게 아바타 이미지가 없으면 이니셜 대신 사람 아이콘을 표시한다.
### 공개 화면 색상 ### 공개 화면 색상
@@ -73,7 +73,7 @@
- 댓글 정렬은 `Best`(좋아요 우선), `Latest`, `Oldest`를 제공한다. - 댓글 정렬은 `Best`(좋아요 우선), `Latest`, `Oldest`를 제공한다.
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다. - 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성 - 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 게시물 상세의 오른쪽 사이드바는 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 제목이 없으면 목차 없음 문구를 표시한다. - 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다.
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다. - 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다. - 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다. - 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
@@ -139,6 +139,7 @@ layouts/
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다. - 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
- 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다. - 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 메인 태그를 드롭다운에서 추가할 수 있다. 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천하고, 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. - 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 메인 태그를 드롭다운에서 추가할 수 있다. 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천하고, 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다.
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 전체 선택은 현재 검색·필터 결과만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다.
- 메뉴 관리 항목은 `네비게이션`으로 표시한다. - 메뉴 관리 항목은 `네비게이션`으로 표시한다.
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다. - 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다. - 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.

View File

@@ -1,5 +1,13 @@
# 업데이트 이력 # 업데이트 이력
## v1.5.14
- 공개 헤더: 로그인 사용자의 아바타 이미지가 없을 때 이니셜 대신 사람 아이콘을 표시하도록 수정.
- 공개 게시글 상세: 모바일처럼 오른쪽 사이드바가 하단으로 내려가는 화면에서는 TOC를 숨기도록 수정.
- 관리자 미디어: 검색창을 글·멤버 검색과 같은 아이콘 포함 입력 스타일로 통일.
- 관리자 미디어: 미디어 라이브러리에서 파일 직접 추가 버튼을 추가하고, 현재 폴더 업로드 시 해당 폴더로 배치되도록 수정.
- 관리자 미디어: 현재 검색·필터 결과 기준 전체 선택과 선택 삭제 기능 추가.
## v1.5.13 ## v1.5.13
- 공개 게시글 TOC: 목차 클릭 시 기본 해시 점프 대신 부드러운 스크롤로 이동하도록 수정. - 공개 게시글 TOC: 목차 클릭 시 기본 해시 점프 대신 부드러운 스크롤로 이동하도록 수정.

View File

@@ -48,7 +48,7 @@ onBeforeUnmount(() => {
/> />
</Transition> </Transition>
<div <div
class="public-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0" class="public-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
:class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'" :class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
> >
<main <main

View File

@@ -48,7 +48,7 @@ onBeforeUnmount(() => {
/> />
</Transition> </Transition>
<div <div
class="post-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0" class="post-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
:class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'" :class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
> >
<main <main

4
package-lock.json generated
View File

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

View File

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

View File

@@ -29,6 +29,9 @@ const selectedMediaUrl = ref('')
const selectedMediaUrls = ref([]) const selectedMediaUrls = ref([])
const lastSelectedIndex = ref(-1) const lastSelectedIndex = ref(-1)
const draggingUrls = ref([]) const draggingUrls = ref([])
const uploadInputRef = ref(null)
const uploadingFiles = ref(false)
const deletingSelected = ref(false)
const { toast, showToast } = useAdminToast() const { toast, showToast } = useAdminToast()
@@ -239,6 +242,22 @@ const filteredMediaItems = computed(() => {
}) })
}) })
const filteredMediaUrls = computed(() => filteredMediaItems.value.map((item) => item.url))
const isAllFilteredMediaSelected = computed(() => {
if (!filteredMediaUrls.value.length) {
return false
}
return filteredMediaUrls.value.every((url) => selectedMediaUrls.value.includes(url))
})
const selectedDeletableMediaItems = computed(() => {
const selectedUrlSet = new Set(selectedMediaUrls.value)
return filteredMediaItems.value.filter((item) => selectedUrlSet.has(item.url) && !isMediaItemLocked(item))
})
/** /**
* 파일 크기 표시 문자열 생성 * 파일 크기 표시 문자열 생성
* @param {number} size - 파일 크기 * @param {number} size - 파일 크기
@@ -323,6 +342,99 @@ const clearMediaSelection = () => {
lastSelectedIndex.value = -1 lastSelectedIndex.value = -1
} }
/**
* 현재 필터링된 미디어 전체 선택을 토글한다.
* @returns {void}
*/
const toggleFilteredMediaSelection = () => {
if (!filteredMediaUrls.value.length) {
return
}
if (isAllFilteredMediaSelected.value) {
const filteredUrlSet = new Set(filteredMediaUrls.value)
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => !filteredUrlSet.has(url))
lastSelectedIndex.value = -1
return
}
selectedMediaUrls.value = [...new Set([
...selectedMediaUrls.value,
...filteredMediaUrls.value
])]
lastSelectedIndex.value = filteredMediaItems.value.length - 1
}
/**
* 파일 직접 업로드 선택창을 연다.
* @returns {void}
*/
const openUploadFilePicker = () => {
if (activeTab.value !== 'library') {
return
}
uploadInputRef.value?.click()
}
/**
* 선택된 파일을 미디어 라이브러리에 업로드한다.
* @param {Event} event - 파일 입력 변경 이벤트
* @returns {Promise<void>}
*/
const uploadSelectedFiles = async (event) => {
if (activeTab.value !== 'library') {
return
}
const input = event.target
if (!(input instanceof HTMLInputElement) || !input.files?.length) {
return
}
const files = Array.from(input.files)
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
uploadingFiles.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
const uploadedUrls = Array.isArray(result?.files)
? result.files.map((file) => file.url).filter(Boolean)
: []
if (activeFolder.value && uploadedUrls.length) {
await $fetch('/admin/api/media', {
method: 'PUT',
body: {
urls: uploadedUrls,
category: activeFolder.value
}
})
}
await Promise.all([
refresh(),
refreshMediaFolders()
])
selectedMediaUrls.value = uploadedUrls
showToast('success', `${files.length}개 파일을 추가했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '파일을 업로드하지 못했습니다.')
} finally {
uploadingFiles.value = false
input.value = ''
}
}
/** /**
* 미디어 상세 모달 열기 * 미디어 상세 모달 열기
* @param {Object} item - 미디어 항목 * @param {Object} item - 미디어 항목
@@ -605,6 +717,62 @@ const deleteMedia = async (item) => {
deletingUrl.value = '' deletingUrl.value = ''
} }
} }
/**
* 선택한 미디어 중 삭제 가능한 항목을 한 번에 삭제한다.
* @returns {Promise<void>}
*/
const deleteSelectedMedia = async () => {
const items = selectedDeletableMediaItems.value
const lockedCount = selectedMediaUrls.value.length - items.length
if (!items.length) {
showToast('error', '삭제할 수 있는 선택 항목이 없습니다.')
return
}
const confirmMessage = lockedCount > 0
? `${items.length}개 파일을 삭제할까요? 잠긴 ${lockedCount}개 항목은 제외됩니다.`
: `${items.length}개 파일을 삭제할까요?`
if (!confirm(confirmMessage)) {
return
}
deletingSelected.value = true
try {
for (const item of items) {
await $fetch('/admin/api/media', {
method: 'DELETE',
body: {
url: item.url
}
})
}
closeMediaDetail()
clearMediaSelection()
await Promise.all([
refresh(),
refreshMediaFolders()
])
showToast('success', `${items.length}개 파일을 삭제했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '선택한 파일을 삭제하지 못했습니다.')
} finally {
deletingSelected.value = false
}
}
watch(filteredMediaUrls, (urls) => {
const visibleUrlSet = new Set(urls)
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => visibleUrlSet.has(url))
if (!selectedMediaUrls.value.length) {
lastSelectedIndex.value = -1
}
})
</script> </script>
<template> <template>
@@ -637,12 +805,37 @@ const deleteMedia = async (item) => {
</button> </button>
</div> </div>
</div> </div>
<input <div class="admin-media__header-actions flex min-w-0 flex-wrap items-center gap-2">
v-model="searchText" <label class="admin-media__search relative">
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72" <span class="sr-only">미디어 검색</span>
type="search" <svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" 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">
:placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'" <path d="m21 21-4.34-4.34" />
> <circle cx="11" cy="11" r="8" />
</svg>
<input
v-model="searchText"
class="admin-media__search-input h-10 w-full rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac] md:w-72"
type="search"
placeholder="미디어 검색"
>
</label>
<button
v-if="activeTab === 'library'"
class="admin-media__upload-button h-10 shrink-0 rounded bg-[#15171a] px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="uploadingFiles"
@click="openUploadFilePicker"
>
{{ uploadingFiles ? '추가 중' : '파일 추가' }}
</button>
<input
ref="uploadInputRef"
class="hidden"
type="file"
multiple
@change="uploadSelectedFiles"
>
</div>
</div> </div>
<div <div
v-if="activeTab === 'library'" v-if="activeTab === 'library'"
@@ -768,11 +961,30 @@ const deleteMedia = async (item) => {
{{ filteredMediaItems.length }} 표시 {{ filteredMediaItems.length }} 표시
</p> </p>
</div> </div>
<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"> <div class="admin-media__selection-actions flex flex-wrap items-center justify-end gap-2">
<strong class="admin-media__selection-count text-ink">{{ selectedMediaUrls.length }} 선택됨</strong> <button
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection"> 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"
:aria-pressed="isAllFilteredMediaSelected"
@click="toggleFilteredMediaSelection"
>
{{ isAllFilteredMediaSelected ? '전체 해제' : '전체 선택' }}
</button> </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"
@click="deleteSelectedMedia"
>
{{ deletingSelected ? '삭제 중' : '선택 삭제' }}
</button>
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
선택 해제
</button>
</div>
</div> </div>
</div> </div>