미디어 업로드와 태그 표시 수정
This commit is contained in:
@@ -130,6 +130,13 @@ const mediaPickerAccept = computed(() => {
|
||||
return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx'
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 카드용 파생 썸네일 URL인지 확인한다.
|
||||
* @param {string|null|undefined} url - 미디어 URL
|
||||
* @returns {boolean} 카드 썸네일 여부
|
||||
*/
|
||||
const isPostCardThumbnailMediaUrl = (url) => /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-card\.webp$/i.test(String(url || ''))
|
||||
|
||||
/** 작성 textarea 최소 높이(px) */
|
||||
const MIN_TEXTAREA_HEIGHT_PX = 620
|
||||
|
||||
@@ -1530,6 +1537,10 @@ const getMediaItemKind = (item) => {
|
||||
* @returns {boolean} 선택 가능 여부
|
||||
*/
|
||||
const isMediaItemSelectableForTarget = (item) => {
|
||||
if (isPostCardThumbnailMediaUrl(item?.url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const kind = getMediaItemKind(item)
|
||||
|
||||
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
|
||||
@@ -2946,7 +2957,7 @@ const uploadMediaFiles = async (files) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAndInsert = async (files, target = 'image') => {
|
||||
if (!files?.length) {
|
||||
if (!files?.length || isUploading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2993,7 +3004,7 @@ const handleFileInput = async (event, target) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFromMediaModal = async (files) => {
|
||||
if (!files?.length) {
|
||||
if (!files?.length || isUploading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3024,6 +3035,11 @@ const uploadFromMediaModal = async (files) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleMediaModalDrop = async (event) => {
|
||||
if (isUploading.value) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const files = Array.from(event.dataTransfer?.files || []).filter(isUploadFileAllowedForPicker)
|
||||
|
||||
if (!files.length) {
|
||||
@@ -3095,6 +3111,10 @@ const mediaPickerUploadHint = computed(() => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handlePaste = async (event) => {
|
||||
if (isUploading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const imageFiles = Array.from(event.clipboardData?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (!imageFiles.length) {
|
||||
@@ -3122,6 +3142,11 @@ const handlePaste = async (event) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleDrop = async (event) => {
|
||||
if (isUploading.value) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const imageFiles = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (!imageFiles.length) {
|
||||
@@ -3413,24 +3438,29 @@ const handleKeydown = (event) => {
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="admin-markdown-editor__media-upload-zone grid min-h-[420px] place-items-center rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
|
||||
class="admin-markdown-editor__media-upload-zone relative grid min-h-[420px] place-items-center overflow-hidden rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
|
||||
:class="isUploading ? 'admin-markdown-editor__media-upload-zone--uploading select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleMediaModalDrop"
|
||||
>
|
||||
<div class="admin-markdown-editor__media-upload-inner grid gap-3 px-6">
|
||||
<p class="admin-markdown-editor__media-upload-title text-lg font-semibold text-[#15171a]">
|
||||
파일을 끌어 업로드
|
||||
{{ isUploading ? '업로드 중입니다' : '파일을 끌어 업로드' }}
|
||||
</p>
|
||||
<p class="admin-markdown-editor__media-upload-or text-sm text-[#6b7280]">
|
||||
또는
|
||||
{{ isUploading ? '잠시만 기다려 주세요.' : '또는' }}
|
||||
</p>
|
||||
<label class="admin-markdown-editor__media-upload-button mx-auto inline-flex h-10 cursor-pointer items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50">
|
||||
<label
|
||||
class="admin-markdown-editor__media-upload-button mx-auto inline-flex h-10 items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50"
|
||||
:class="isUploading ? 'cursor-not-allowed border-[#c8ced3] text-[#8e9cac] hover:bg-transparent' : 'cursor-pointer'"
|
||||
>
|
||||
{{ isUploading ? '업로드 중' : '파일 선택' }}
|
||||
<input
|
||||
class="sr-only"
|
||||
type="file"
|
||||
:accept="mediaPickerAccept"
|
||||
:multiple="isGalleryMediaPicker"
|
||||
:disabled="isUploading"
|
||||
@change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
|
||||
>
|
||||
</label>
|
||||
@@ -3438,6 +3468,17 @@ const handleKeydown = (event) => {
|
||||
{{ mediaPickerUploadHint }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="isUploading"
|
||||
class="admin-markdown-editor__media-upload-overlay pointer-events-none absolute inset-0 grid place-items-center bg-white/72 backdrop-blur-[1px]"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="admin-markdown-editor__media-upload-loading grid w-full max-w-sm gap-4 px-8 text-center">
|
||||
<span class="admin-markdown-editor__media-upload-spinner mx-auto size-8 rounded-full border-2 border-[#d7dde2] border-t-[#15171a]" aria-hidden="true" />
|
||||
<strong class="text-sm font-semibold text-[#15171a]">업로드 중입니다</strong>
|
||||
<span class="admin-markdown-editor__media-upload-skeleton h-2 overflow-hidden rounded-full bg-[#e3e6e8]" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3480,4 +3521,49 @@ const handleKeydown = (event) => {
|
||||
.admin-markdown-editor__gutter::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-markdown-editor__media-upload-zone--uploading::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
background: linear-gradient(110deg, transparent 0%, rgba(255, 255, 255, 0.72) 45%, transparent 72%);
|
||||
transform: translateX(-100%);
|
||||
animation: admin-markdown-editor-upload-sheen 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.admin-markdown-editor__media-upload-spinner {
|
||||
animation: admin-markdown-editor-upload-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.admin-markdown-editor__media-upload-skeleton::before {
|
||||
display: block;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.86), transparent);
|
||||
animation: admin-markdown-editor-upload-skeleton 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes admin-markdown-editor-upload-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes admin-markdown-editor-upload-sheen {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes admin-markdown-editor-upload-skeleton {
|
||||
from {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(260%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
21
db/migrations/055_add_post_tag_sort_order.sql
Normal file
21
db/migrations/055_add_post_tag_sort_order.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE post_tags
|
||||
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
WITH ordered_post_tags AS (
|
||||
SELECT
|
||||
post_id,
|
||||
tag_id,
|
||||
(ROW_NUMBER() OVER (
|
||||
PARTITION BY post_id
|
||||
ORDER BY created_at ASC, tag_id ASC
|
||||
) - 1) * 10 AS next_sort_order
|
||||
FROM post_tags
|
||||
)
|
||||
UPDATE post_tags
|
||||
SET sort_order = ordered_post_tags.next_sort_order
|
||||
FROM ordered_post_tags
|
||||
WHERE post_tags.post_id = ordered_post_tags.post_id
|
||||
AND post_tags.tag_id = ordered_post_tags.tag_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_tags_post_id_sort_order_idx
|
||||
ON post_tags (post_id, sort_order ASC, created_at ASC);
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.91
|
||||
|
||||
- 글쓰기 본문 미디어 선택 창에서 카드 썸네일 파생 이미지가 중복으로 보이지 않게 했다.
|
||||
- 미디어 업로드 중에는 추가 드롭·파일 선택을 막고 업로드 중 로딩 표시를 보여 주도록 했다.
|
||||
- 공개 게시물 목록은 첫 번째 태그를 기준으로 표시하고, 관리자 게시물 목록은 적용된 태그 전체를 보여 주도록 정리했다.
|
||||
|
||||
## v1.5.90
|
||||
|
||||
- 라이브 글쓰기에서 코드블럭을 빠져나온 뒤에도 오른쪽 코드블럭 설정 패널이 남아 있던 문제를 다시 수정했다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.5.90에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.5.91에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### v1.5.91 참고
|
||||
|
||||
- DB 마이그레이션 `055_add_post_tag_sort_order.sql` 적용이 필요하다.
|
||||
- 게시물 작성 본문에서 `/이미지`로 미디어 선택 모달을 열었을 때 `thumbs/*-card.webp` 카드 썸네일이 보이지 않는지 확인한다.
|
||||
- 미디어 모달 업로드 탭에 파일을 드롭한 뒤 업로드 중 표시가 나오고 추가 드롭·파일 선택이 막히는지 확인한다.
|
||||
- 태그를 여러 개 가진 공개 게시물 목록에서 첫 번째 태그가 고정 표시되고, 관리자 게시물 목록에서는 모든 태그가 표시되는지 확인한다.
|
||||
|
||||
### v1.5.90 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-09 v1.5.91 — 게시물 태그 표시 순서는 저장 순서로 고정한다
|
||||
|
||||
게시물에 여러 태그를 설정했을 때 공개 목록은 작성자가 입력한 첫 번째 태그를 기준으로 보여 주어야 한다. 기존 `post_tags` 연결 테이블에는 게시물 안에서의 순서가 없어 PostgreSQL `array_agg` 결과가 안정적이지 않을 수 있으므로, `post_tags.sort_order`를 추가해 저장 시 입력 순서를 기록하고 조회 시 이 값을 기준으로 정렬한다. 관리자 게시물 목록은 운영 확인용 화면이므로 대표 태그 하나가 아니라 적용된 태그 전체를 보여 준다.
|
||||
|
||||
## 2026-06-08 v1.5.82 — sitemap은 파일이 아니라 공개 DB 기준 동적 응답으로 제공한다
|
||||
|
||||
게시물을 발행할 때마다 정적 sitemap 파일을 다시 쓰는 방식은 운영 볼륨과 배포 산출물의 경계가 흐려지고, 예약 발행·검색엔진 노출 제외 상태와 어긋나기 쉽다. sitemap은 요청 시점의 공개 DB 상태에서 만들고, `noindex` 글과 멤버십·비공개·초안·예약 대기 글은 제외한다. robots.txt는 sitemap 위치를 명시해 검색엔진이 RSS나 내부 링크만 추적하지 않아도 공개 URL 목록을 찾을 수 있게 한다.
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·`block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·`block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
@@ -148,7 +148,7 @@
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
|
||||
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
@@ -340,6 +340,7 @@
|
||||
| db/migrations/044_site_settings_custom_code.sql | 사이트 설정 ads.txt·공통 헤더 코드·공통 푸터 코드 컬럼 추가 |
|
||||
| db/migrations/045_analytics_traffic_sources.sql | 방문자 유입원·디바이스·키워드 일별 집계 테이블 추가 |
|
||||
| db/migrations/054_add_post_show_featured_image.sql | 게시물 상세 제목 아래 대표 이미지 표시 여부 컬럼 추가 |
|
||||
| db/migrations/055_add_post_tag_sort_order.sql | 게시물별 태그 표시 순서(`post_tags.sort_order`) 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ components/content/
|
||||
- 파일: `:::file` ~ `:::` (`url`, `title`, `description`, `name`, `size`) — 다운로드 링크 카드
|
||||
- 렌더링: `ProseVideo.vue`, `ProseAudio.vue`, `ProseFile.vue`
|
||||
- 관리자 슬래시: `/video`, `/audio`, `/file`로 빈 템플릿 삽입 후 URL·메타 수정
|
||||
- 관리자 미디어 화면은 미디어 라이브러리·카드 썸네일·프로필 이미지 탭으로 구분한다. 미디어 라이브러리 탭은 원본 업로드 파일만 표시하고, 게시물 목록용 `thumbs/*-card.webp` 파생 파일은 카드 썸네일 탭에만 표시한다.
|
||||
- 관리자 미디어 화면은 미디어 라이브러리·카드 썸네일·프로필 이미지 탭으로 구분한다. 미디어 라이브러리 탭은 원본 업로드 파일만 표시하고, 게시물 목록용 `thumbs/*-card.webp` 파생 파일은 카드 썸네일 탭에만 표시한다. 게시물 본문 글쓰기의 `/이미지`·미디어 선택 모달에서도 `thumbs/*-card.webp` 파생 파일은 숨겨 원본과 카드 썸네일이 중복 선택되지 않게 한다.
|
||||
- 관리자 미디어 화면의 미디어 라이브러리 탭은 전체·이미지·영상·음악·파일 종류 필터와 미사용 필터를 제공한다. 미사용은 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 항목을 의미한다. 비디오 항목은 브라우저에서 초반 프레임을 캔버스로 추출해 목록 썸네일로 표시하고, 추출 실패 시 `video` placeholder를 유지한다.
|
||||
- 카드 썸네일 탭의 항목은 원본 대표 이미지가 사용 중이면 `목록 카드 썸네일` 사용처를 가진 것으로 판정한다. 원본 대표 이미지가 사용 중이지만 카드 썸네일 파일이 없으면 원본 항목에 `원본` 배지와 “목록에서 원본 이미지를 불러옴” 상태를 표시한다. 카드 썸네일은 원본과의 연결을 유지하기 위해 폴더 이동과 파일명 변경을 막고, 사용 중이면 삭제도 막는다.
|
||||
- 문단과 줄바꿈
|
||||
@@ -306,7 +306,7 @@ components/content/
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
> API 응답의 게시물 객체는 원본 대표 이미지 `featuredImage`, 상세 상단 표시 여부 `showFeaturedImage`, 목록용 카드 썸네일 `featuredImageThumbnail`, `isFeatured`, `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
|
||||
> API 응답의 게시물 객체는 원본 대표 이미지 `featuredImage`, 상세 상단 표시 여부 `showFeaturedImage`, 목록용 카드 썸네일 `featuredImageThumbnail`, `isFeatured`, `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다. `tags`는 게시물에 저장된 태그 연결 순서(`post_tags.sort_order`)를 따른다.
|
||||
> 공개 게시물 목록·상세는 `published` 상태만 기본 노출하며, `members` 상태는 VIP 이상 등급(`vip`/`admin`/`owner`) 회원에게만 노출한다. `private`와 `draft`는 공개 화면에서 노출하지 않는다.
|
||||
|
||||
### PostExportJobs / PostExportFiles
|
||||
@@ -470,6 +470,7 @@ components/content/
|
||||
|------|------|------|
|
||||
| post_id | UUID | FK → Posts |
|
||||
| tag_id | UUID | FK → Tags |
|
||||
| sort_order | Integer | 게시물 내 태그 표시 순서 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
|
||||
### Analytics (자체 최소 통계)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.91
|
||||
|
||||
- 게시물 글쓰기: 본문 미디어 선택 모달에서 게시물 카드 썸네일 파생 파일을 숨기도록 수정.
|
||||
- 게시물 글쓰기: 미디어 모달 업로드 중 드롭·파일 선택 재진입을 막고 업로드 중 오버레이·스피너·스켈레톤 로딩 표시 추가.
|
||||
- 게시물 태그: 게시물별 태그 연결 순서 저장용 `post_tags.sort_order` 마이그레이션 추가.
|
||||
- 공개 게시물 목록: 저장된 첫 번째 태그를 기준으로 태그를 고정 표시하도록 태그 조회 정렬 보강.
|
||||
- 관리자 게시물 목록: 게시물에 적용된 태그 전체를 배지로 표시하도록 수정.
|
||||
|
||||
## v1.5.90
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드에서 닫는 코드 펜스를 다음 코드 블록 시작으로 오인해 일반 문단에서도 코드 블록 설정 패널이 남던 문제 수정.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.90",
|
||||
"version": "1.5.91",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.90",
|
||||
"version": "1.5.91",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.90",
|
||||
"version": "1.5.91",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -180,13 +180,6 @@ const createTagBadgeStyle = (color) => ({
|
||||
color
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물의 대표 태그 슬러그를 반환한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {string} 첫 번째 태그 슬러그
|
||||
*/
|
||||
const getRepresentativeTagSlug = (post) => post.tags?.[0] || ''
|
||||
|
||||
const usedTagSlugs = computed(() => {
|
||||
const slugs = new Set()
|
||||
for (const post of posts.value) {
|
||||
@@ -591,12 +584,14 @@ watch(openPostMenuId, async (postId) => {
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<div v-if="getRepresentativeTagSlug(post)" class="admin-posts__tag-list flex flex-wrap gap-1.5">
|
||||
<div v-if="post.tags?.length" class="admin-posts__tag-list flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="tag in post.tags"
|
||||
:key="tag"
|
||||
class="admin-posts__tag-badge inline-flex h-6 items-center rounded-[3px] border px-2 text-xs font-semibold"
|
||||
:style="createTagBadgeStyle(getTagColor(getRepresentativeTagSlug(post)))"
|
||||
:style="createTagBadgeStyle(getTagColor(tag))"
|
||||
>
|
||||
{{ getTagName(getRepresentativeTagSlug(post)) }}
|
||||
{{ getTagName(tag) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="admin-posts__tag-empty text-muted">-</span>
|
||||
|
||||
@@ -221,7 +221,7 @@ const syncPostTags = async (sql, postId, tags) => {
|
||||
WHERE post_id = ${postId}
|
||||
`
|
||||
|
||||
for (const slug of tagSlugs) {
|
||||
for (const [index, slug] of tagSlugs.entries()) {
|
||||
const tagRows = await sql`
|
||||
INSERT INTO tags (name, slug, tag_type, sort_order)
|
||||
VALUES (${getTagNameFromSlug(slug)}, ${slug}, 'general', 0)
|
||||
@@ -231,9 +231,10 @@ const syncPostTags = async (sql, postId, tags) => {
|
||||
`
|
||||
|
||||
await sql`
|
||||
INSERT INTO post_tags (post_id, tag_id)
|
||||
VALUES (${postId}, ${tagRows[0].id})
|
||||
ON CONFLICT DO NOTHING
|
||||
INSERT INTO post_tags (post_id, tag_id, sort_order)
|
||||
VALUES (${postId}, ${tagRows[0].id}, ${index * 10})
|
||||
ON CONFLICT (post_id, tag_id) DO UPDATE
|
||||
SET sort_order = EXCLUDED.sort_order
|
||||
`
|
||||
}
|
||||
}
|
||||
@@ -259,7 +260,7 @@ export const listPosts = async ({ includeMembership = false } = {}) => {
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
COALESCE(array_agg(tags.slug ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||
@@ -299,7 +300,7 @@ export const listAdminPosts = async () => {
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
COALESCE(array_agg(tags.slug ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||
@@ -331,7 +332,7 @@ export const getAdminPostById = async (id) => {
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
COALESCE(array_agg(tags.slug ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||
@@ -502,7 +503,7 @@ export const getPostBySlug = async (slug, { includeMembership = false } = {}) =>
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
COALESCE(array_agg(tags.slug ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||
|
||||
Reference in New Issue
Block a user