Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b331b8fe6 | |||
| 069d1bfbd4 | |||
| 965a8fd1f6 | |||
| 020471a1b8 | |||
| 52f22b4ff1 | |||
| bebf7ee1c9 | |||
| 6481f958f5 | |||
| 79d0a30475 | |||
| fb0dadb7b9 | |||
| b4f3fdb77d | |||
| 6cb6268b43 | |||
| b490d5b90f | |||
| ec9f9ea57f | |||
| 6e25cdfd60 | |||
| 5031b9de22 | |||
| 003fb86fad | |||
| 6a059a9a59 | |||
| 996965740f | |||
| 4e1056311d | |||
| 8b8a80034d | |||
| bcff96aa4c | |||
| 4de5589bcb | |||
| c1242e1409 | |||
| 16bb9370fa | |||
| 21024602b0 | |||
| 05176609ee | |||
| 9974e0d137 | |||
| 1d9a3e4527 | |||
| 79fb354d91 | |||
| bd71ca860c | |||
| cdc16c72b2 | |||
| b18aca4dcc | |||
| 080f76799a | |||
| a314c96c4d | |||
| ede272e7b1 | |||
| 65af30724c | |||
| eab800b6c1 | |||
| 6e8ca97779 | |||
| f5cd73b223 | |||
| 91573a31d6 | |||
| 2cb5c1a281 | |||
| 1b00dac21c | |||
| ff6526c997 | |||
| bcf3acd432 | |||
| 5141a63294 | |||
| 3f7f51ff86 | |||
| fd55d8af08 | |||
| ed7709ab59 | |||
| 2f7ce64391 | |||
| 8daec2806b | |||
| e769595c5d | |||
| 94a37f451d | |||
| 3916bcb284 | |||
| f3f971ab1b | |||
| add0fa51c0 | |||
| 4b1ab9e00e | |||
| 3cb1290711 | |||
| 4704748582 | |||
| 082c6a9619 | |||
| 5f2b2b8c4f | |||
| 41406ca852 | |||
| a439af5b62 | |||
| e9161676e9 | |||
| 9363c10451 | |||
| 1a4086336f | |||
| 5e485eb3ec | |||
| 4554801294 | |||
| 34314a5c7d | |||
| 4d7aaa90ca | |||
| 59ea51e550 | |||
| d47134c46d | |||
| 97d2d8ffb3 | |||
| f757c3db78 | |||
| 0f60039126 | |||
| 4e5ccb2726 | |||
| 38f8abb1ff | |||
| 5bda4d5472 | |||
| 398877fd92 | |||
| 60c5c3d5c9 | |||
| 9054c9625c | |||
| 1ef50c111b | |||
| e506a343bc | |||
| 8c5ccc94ec | |||
| fc5f41b9cc | |||
| 60f9fd52f0 | |||
| db87542096 | |||
| dd0a643d73 | |||
| 04b8a7006a | |||
| 27cf05aba6 | |||
| d5666fdcc3 | |||
| 792460b27a | |||
| 722e027f18 | |||
| 6bc697bd95 | |||
| 77191ef7da | |||
| f3db10f015 | |||
| a7fcd7dce5 | |||
| e1254c6b5f | |||
| bc531f81db | |||
| 83ac51fd11 | |||
| 18ca11f9bb | |||
| 49de0e277c | |||
| 3afef9d0d2 | |||
| c2b3e3a204 | |||
| 10bf6b422e | |||
| 0fd18bfb48 | |||
| 787747aa7f | |||
| 237eb2990f | |||
| 5ee6fcd54b | |||
| cbf5ed6c8c | |||
| a3acd9320a | |||
| 37f6c38caa |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
*.log
|
||||||
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgres://sori_studio:replace-with-random-password@sori-studio-db:5432/sori_studio
|
||||||
|
DATABASE_NAME=sori_studio
|
||||||
|
POSTGRES_DB=sori_studio
|
||||||
|
POSTGRES_USER=sori_studio
|
||||||
|
POSTGRES_PASSWORD=replace-with-random-password
|
||||||
|
DB_PORT=43119
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=replace-with-random-password
|
||||||
|
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||||
|
|
||||||
|
# Upload
|
||||||
|
UPLOAD_DIR=/uploads
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
AVATAR_MIN_WIDTH=96
|
||||||
|
AVATAR_MIN_HEIGHT=96
|
||||||
|
AVATAR_MAX_WIDTH=512
|
||||||
|
AVATAR_MAX_HEIGHT=512
|
||||||
|
AVATAR_WEBP_QUALITY=82
|
||||||
|
|
||||||
|
# Site
|
||||||
|
NUXT_PUBLIC_SITE_URL=https://sori.studio
|
||||||
|
NUXT_PUBLIC_SITE_TITLE=sori.studio
|
||||||
|
|
||||||
|
# Transactional email (Resend, optional — 회원가입 OTP·비밀번호 찾기)
|
||||||
|
# RESEND_API_KEY=
|
||||||
|
# RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||||
|
# EMAIL_OTP_PEPPER= ← 선택. OTP를 DB에 해시해 저장할 때 섞는 서버 전용 비밀(긴 난문자열 권장, 예: openssl rand -hex 32). 비우면 MEMBER_SESSION_SECRET을 대신 사용.
|
||||||
|
|
||||||
|
# Server
|
||||||
|
APP_PORT=43118
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules/
|
|||||||
.output/
|
.output/
|
||||||
.nuxt/
|
.nuxt/
|
||||||
dist/
|
dist/
|
||||||
|
public/uploads/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
@@ -26,4 +27,8 @@ Thumbs.db
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Reference theme sample (do not commit)
|
||||||
|
ZCF-v1.0.5/
|
||||||
|
sample 깃에 올리지말것/
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
|
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
|
||||||
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
|
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
|
||||||
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
||||||
|
- 커밋까지 완료한 작업은 사용자가 중단을 요청하지 않는 한 `git push`로 원격에 반영해 푸시 누락을 피한다.
|
||||||
|
|
||||||
민감 정보 예시:
|
민감 정보 예시:
|
||||||
- 실명
|
- 실명
|
||||||
@@ -157,6 +158,7 @@
|
|||||||
- 기존 API 호출 패턴을 따른다.
|
- 기존 API 호출 패턴을 따른다.
|
||||||
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
|
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
|
||||||
- 하드코딩된 값 사용을 금지한다.
|
- 하드코딩된 값 사용을 금지한다.
|
||||||
|
- 초기 백엔드는 별도 앱으로 분리하지 않고 Nuxt `server/api`를 사용한다.
|
||||||
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
|
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
|
||||||
- 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다.
|
- 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다.
|
||||||
|
|
||||||
|
|||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:22-alpine AS dependencies
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
COPY --from=builder /app/.output ./.output
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
29
app.vue
Normal file
29
app.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup>
|
||||||
|
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
|
||||||
|
default: () => ({
|
||||||
|
title: 'sori.studio',
|
||||||
|
faviconUrl: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
titleTemplate: (titleChunk) => titleChunk
|
||||||
|
? `${titleChunk} · ${appSiteSettings.value.title}`
|
||||||
|
: appSiteSettings.value.title,
|
||||||
|
link: appSiteSettings.value.faviconUrl
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/png',
|
||||||
|
href: appSiteSettings.value.faviconUrl
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
238
assets/css/main.css
Normal file
238
assets/css/main.css
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--site-bg: #fcfcfc;
|
||||||
|
--site-panel: #fcfcfc;
|
||||||
|
--site-panel-strong: #fcfcfc;
|
||||||
|
--site-text: #111111;
|
||||||
|
--site-muted: #454545;
|
||||||
|
--site-soft: #6f7480;
|
||||||
|
--site-line: #e2e2e0;
|
||||||
|
--site-input: #fcfcfc;
|
||||||
|
--site-accent: #ff4f2e;
|
||||||
|
--site-accent-text: #ffffff;
|
||||||
|
--site-invert: #111111;
|
||||||
|
--site-invert-text: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--site-bg: #050505;
|
||||||
|
--site-panel: #080808;
|
||||||
|
--site-panel-strong: #0d0d0d;
|
||||||
|
--site-text: #f4f4f2;
|
||||||
|
--site-muted: #c7c7c2;
|
||||||
|
--site-soft: #8b8e96;
|
||||||
|
--site-line: #252525;
|
||||||
|
--site-input: #171717;
|
||||||
|
--site-accent: #ff4f2e;
|
||||||
|
--site-accent-text: #ffffff;
|
||||||
|
--site-invert: #f4f4f2;
|
||||||
|
--site-invert-text: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme='light']) {
|
||||||
|
--site-bg: #050505;
|
||||||
|
--site-panel: #080808;
|
||||||
|
--site-panel-strong: #0d0d0d;
|
||||||
|
--site-text: #f4f4f2;
|
||||||
|
--site-muted: #c7c7c2;
|
||||||
|
--site-soft: #8b8e96;
|
||||||
|
--site-line: #252525;
|
||||||
|
--site-input: #171717;
|
||||||
|
--site-accent: #ff4f2e;
|
||||||
|
--site-accent-text: #ffffff;
|
||||||
|
--site-invert: #f4f4f2;
|
||||||
|
--site-invert-text: #111111;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: Pretendard, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
color: var(--site-text);
|
||||||
|
background: var(--site-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.site-mobile-nav-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.site-search-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--site-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.admin-post-editor-document,
|
||||||
|
body.admin-post-editor-document {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
@keyframes site-search-modal-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px) scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--site-text);
|
||||||
|
background: var(--site-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-section {
|
||||||
|
border-bottom: 1px solid var(--site-line);
|
||||||
|
background: var(--site-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-section-header {
|
||||||
|
@apply px-6 py-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-section-body {
|
||||||
|
@apply px-6 py-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-search-modal__panel--animate {
|
||||||
|
animation: site-search-modal-in 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-prose {
|
||||||
|
@apply max-w-none text-[17px] leading-8;
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
height: 57px;
|
||||||
|
border-bottom: 1px solid var(--site-line);
|
||||||
|
background: var(--site-panel);
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-main {
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--site-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-main--menu-closed {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-sidebar {
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--site-panel);
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사이드바 내부 스크롤 영역 — 스크롤바만 숨기고 스크롤 동작은 유지한다.
|
||||||
|
*/
|
||||||
|
.site-sidebar-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-sidebar-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-sidebar-section {
|
||||||
|
border-bottom: 1px solid var(--site-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-muted {
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-soft {
|
||||||
|
color: var(--site-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-input {
|
||||||
|
border: 1px solid var(--site-line);
|
||||||
|
background: var(--site-input);
|
||||||
|
color: var(--site-text);
|
||||||
|
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-button {
|
||||||
|
background: var(--site-invert);
|
||||||
|
color: var(--site-invert-text);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-accent-button {
|
||||||
|
background: var(--site-accent);
|
||||||
|
color: var(--site-accent-text);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-accent-button:hover {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-interactive {
|
||||||
|
transition: color 0.2s ease, background-color 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-interactive:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-input:hover,
|
||||||
|
.site-input:focus-visible {
|
||||||
|
border-color: color-mix(in srgb, var(--site-text) 24%, var(--site-line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-panel-hover {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-panel-hover:hover {
|
||||||
|
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다크 인증 폼(signin/signup) 텍스트 입력 — UA가 부모 color를 상속하지 않는 경우 대비
|
||||||
|
*/
|
||||||
|
.auth-form-input {
|
||||||
|
color: #f5f7fa;
|
||||||
|
caret-color: #2f6feb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-input::placeholder {
|
||||||
|
color: #5c6570;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-input:-webkit-autofill,
|
||||||
|
.auth-form-input:-webkit-autofill:hover,
|
||||||
|
.auth-form-input:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: #f5f7fa;
|
||||||
|
transition: background-color 9999s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
2545
components/admin/AdminBlockEditor.vue
Normal file
2545
components/admin/AdminBlockEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
646
components/admin/AdminMemberForm.vue
Normal file
646
components/admin/AdminMemberForm.vue
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
member: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'edit'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['saved', 'deleted'])
|
||||||
|
|
||||||
|
const isNewMember = computed(() => props.mode === 'new')
|
||||||
|
const saveMessage = ref('')
|
||||||
|
const saveError = ref('')
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const savedMemberSnapshot = ref('')
|
||||||
|
const avatarInputRef = ref(null)
|
||||||
|
const isUploadingAvatar = ref(false)
|
||||||
|
const actionMenuOpen = ref(false)
|
||||||
|
const passwordModalOpen = ref(false)
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const isUpdatingPassword = ref(false)
|
||||||
|
const isDeletingMember = ref(false)
|
||||||
|
const actionMessage = ref('')
|
||||||
|
const actionError = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
avatarUrl: '',
|
||||||
|
labelsText: '',
|
||||||
|
note: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordForm = reactive({
|
||||||
|
password: '',
|
||||||
|
passwordConfirm: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteForm = reactive({
|
||||||
|
confirmText: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 폼 값을 현재 회원 정보로 동기화한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const syncMemberForm = () => {
|
||||||
|
const member = props.member || {}
|
||||||
|
form.username = member.username || ''
|
||||||
|
form.email = member.email || ''
|
||||||
|
form.avatarUrl = member.avatarUrl || ''
|
||||||
|
form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : ''
|
||||||
|
form.note = member.note || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.member, syncMemberForm, { immediate: true })
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
if (isNewMember.value) {
|
||||||
|
return '새 멤버'
|
||||||
|
}
|
||||||
|
|
||||||
|
return form.username || props.member?.email || '멤버'
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberInitial = computed(() => String(form.username || form.email || '?').slice(0, 1).toUpperCase())
|
||||||
|
const noteLength = computed(() => form.note.length)
|
||||||
|
|
||||||
|
const normalizedLabels = computed(() => [...new Set(
|
||||||
|
form.labelsText
|
||||||
|
.split(',')
|
||||||
|
.map((label) => label.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 저장 요청 본문을 문자열로 직렬화한다.
|
||||||
|
* @returns {string} 직렬화된 회원 입력값
|
||||||
|
*/
|
||||||
|
const serializeMemberPayload = () => JSON.stringify(getMemberPayload())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 화면 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 활동 시각을 상대 시간으로 표시한다.
|
||||||
|
* @param {string | null} value - ISO 시각
|
||||||
|
* @returns {string} 상대 시간
|
||||||
|
*/
|
||||||
|
const formatRelativeTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '최근 활동 없음'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '최근 활동 없음'
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = Date.now() - date.getTime()
|
||||||
|
const minute = 1000 * 60
|
||||||
|
const hour = minute * 60
|
||||||
|
const day = hour * 24
|
||||||
|
|
||||||
|
if (diffMs < minute) {
|
||||||
|
return '방금 전'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < hour) {
|
||||||
|
return `${Math.floor(diffMs / minute)}분 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < day) {
|
||||||
|
return `${Math.floor(diffMs / hour)}시간 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < day * 30) {
|
||||||
|
return `${Math.floor(diffMs / day)}일 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 저장 요청 본문을 만든다.
|
||||||
|
* @returns {{ username: string, email: string, avatarUrl: string, labels: string[], note: string }} 저장 본문
|
||||||
|
*/
|
||||||
|
const getMemberPayload = () => ({
|
||||||
|
username: form.username.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
avatarUrl: form.avatarUrl.trim(),
|
||||||
|
labels: normalizedLabels.value,
|
||||||
|
note: form.note
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isUnsavedModalOpen,
|
||||||
|
stayOnUnsavedPage,
|
||||||
|
leaveUnsavedPage
|
||||||
|
} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 썸네일 파일 선택창을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openAvatarFilePicker = () => {
|
||||||
|
avatarInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 썸네일 파일을 업로드하고 폼에 반영한다.
|
||||||
|
* @param {Event} event - 파일 선택 이벤트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const uploadAvatar = async (event) => {
|
||||||
|
const target = event.target instanceof HTMLInputElement ? event.target : null
|
||||||
|
const file = target?.files?.[0]
|
||||||
|
|
||||||
|
if (!file || isUploadingAvatar.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploadingAvatar.value = true
|
||||||
|
saveError.value = ''
|
||||||
|
saveMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('files', file)
|
||||||
|
const result = await $fetch('/admin/api/uploads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
form.avatarUrl = result.files?.[0]?.url || ''
|
||||||
|
} catch (error) {
|
||||||
|
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isUploadingAvatar.value = false
|
||||||
|
if (target) {
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 썸네일 연결을 제거한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const removeAvatar = () => {
|
||||||
|
form.avatarUrl = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 작업 메뉴를 토글한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleActionMenu = () => {
|
||||||
|
actionMenuOpen.value = !actionMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 작업 메뉴를 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeActionMenu = () => {
|
||||||
|
actionMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openPasswordModal = () => {
|
||||||
|
passwordForm.password = ''
|
||||||
|
passwordForm.passwordConfirm = ''
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
passwordModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openDeleteModal = () => {
|
||||||
|
deleteForm.confirmText = ''
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closePasswordModal = () => {
|
||||||
|
if (isUpdatingPassword.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeDeleteModal = () => {
|
||||||
|
if (isDeletingMember.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 권한으로 회원 비밀번호를 변경한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const updateMemberPassword = async () => {
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
|
||||||
|
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
|
||||||
|
actionError.value = '새 비밀번호는 8~32자로 입력해 주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordForm.password !== passwordForm.passwordConfirm) {
|
||||||
|
actionError.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingPassword.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/members/${props.member.id}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
password: passwordForm.password
|
||||||
|
}
|
||||||
|
})
|
||||||
|
passwordModalOpen.value = false
|
||||||
|
passwordForm.password = ''
|
||||||
|
passwordForm.passwordConfirm = ''
|
||||||
|
actionMessage.value = '비밀번호가 변경되었습니다.'
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isUpdatingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 권한으로 회원을 삭제한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteMember = async () => {
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
|
||||||
|
if (deleteForm.confirmText !== form.email) {
|
||||||
|
actionError.value = '삭제하려면 회원 이메일을 정확히 입력해 주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeletingMember.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
emit('deleted')
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isDeletingMember.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 기본 정보를 저장한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const saveMember = async () => {
|
||||||
|
if (isSaving.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMessage.value = ''
|
||||||
|
saveError.value = ''
|
||||||
|
isSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = getMemberPayload()
|
||||||
|
const saved = isNewMember.value
|
||||||
|
? await $fetch('/admin/api/members', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
: await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
savedMemberSnapshot.value = serializeMemberPayload()
|
||||||
|
emit('saved', saved)
|
||||||
|
saveMessage.value = '저장되었습니다.'
|
||||||
|
} catch (error) {
|
||||||
|
saveError.value = error?.data?.message || '저장에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.member, () => {
|
||||||
|
savedMemberSnapshot.value = serializeMemberPayload()
|
||||||
|
}, { immediate: true, flush: 'post' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-member-form bg-paper p-6">
|
||||||
|
<div class="admin-member-form__header sticky top-0 z-10 -mx-6 -mt-6 border-b border-line bg-paper/95 px-6 py-5 backdrop-blur">
|
||||||
|
<div class="admin-member-form__header-inner flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div class="admin-member-form__title-block">
|
||||||
|
<div class="admin-member-form__breadcrumb flex items-center gap-2 text-sm text-[#8a95a5]">
|
||||||
|
<NuxtLink class="admin-member-form__breadcrumb-link text-[#3f4650] hover:text-[#15171a]" to="/admin/members">
|
||||||
|
멤버
|
||||||
|
</NuxtLink>
|
||||||
|
<svg class="h-3 w-3" viewBox="0 0 18 27" aria-hidden="true">
|
||||||
|
<path d="M2.397 25.426l13.143-11.5-13.143-11.5" stroke-width="3" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ isNewMember ? '새 멤버' : '멤버 편집' }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="admin-member-form__title mt-4 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||||
|
{{ pageTitle }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="admin-member-form__actions flex items-center gap-3">
|
||||||
|
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
|
||||||
|
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
|
||||||
|
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
|
||||||
|
비밀번호 변경
|
||||||
|
</button>
|
||||||
|
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
|
||||||
|
멤버 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:opacity-50" type="button" :disabled="isSaving" @click="saveMember">
|
||||||
|
{{ isSaving ? '저장 중' : '저장' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
|
||||||
|
<aside class="admin-member-form__summary">
|
||||||
|
<div class="admin-member-form__identity flex items-center gap-4">
|
||||||
|
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
|
||||||
|
<button
|
||||||
|
class="admin-member-form__avatar-button relative h-20 w-20 overflow-hidden rounded-full bg-[#15171a] text-white"
|
||||||
|
type="button"
|
||||||
|
:aria-label="form.avatarUrl ? '썸네일 변경' : '썸네일 등록'"
|
||||||
|
@click="openAvatarFilePicker"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="form.avatarUrl"
|
||||||
|
class="admin-member-form__avatar h-full w-full object-cover"
|
||||||
|
:src="form.avatarUrl"
|
||||||
|
:alt="pageTitle"
|
||||||
|
>
|
||||||
|
<span v-else class="admin-member-form__avatar flex h-full w-full items-center justify-center text-2xl font-semibold">
|
||||||
|
{{ memberInitial }}
|
||||||
|
</span>
|
||||||
|
<span class="admin-member-form__avatar-caption absolute inset-x-0 bottom-0 flex min-h-8 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||||
|
{{ isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="form.avatarUrl"
|
||||||
|
class="admin-member-form__avatar-remove absolute right-0 top-0 grid size-6 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 shadow-sm transition hover:bg-[#d21a26] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||||
|
type="button"
|
||||||
|
aria-label="썸네일 제거"
|
||||||
|
@click.stop="removeAvatar"
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input ref="avatarInputRef" class="sr-only" type="file" accept="image/*" @change="uploadAvatar">
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2>
|
||||||
|
<p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isNewMember" class="admin-member-form__meta mt-10 space-y-3 text-sm text-[#4d5663]">
|
||||||
|
<p class="flex items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 26" aria-hidden="true">
|
||||||
|
<path d="M12 14.75a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M21 10.75c0 7.9-6.932 12.331-8.629 13.3a.751.751 0 01-.743 0C9.931 23.08 3 18.648 3 10.75a9 9 0 1118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{{ member?.lastSeenIp || '접속 IP 없음' }}
|
||||||
|
</p>
|
||||||
|
<p class="flex items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
|
||||||
|
<path d="M13 5.001c-4.03-.078-8.2 3.157-10.82 6.47-.276.35-.428.805-.428 1.277 0 .472.152.928.427 1.278C4.743 17.27 8.9 20.578 13 20.5c4.1.079 8.258-3.23 10.824-6.473.275-.35.428-.806.428-1.278s-.153-.927-.428-1.278C21.2 8.158 17.031 4.923 13 5.001z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M16.75 12.751a3.75 3.75 0 11-7.5-.002 3.75 3.75 0 017.5.002z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{{ formatRelativeTime(member?.lastSeenAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">가입 정보</h3>
|
||||||
|
<p class="mt-5 flex items-center gap-2 text-sm text-[#4d5663]">
|
||||||
|
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M11.5 12c-2.824 0-2.83.024-4.5.53-3.5 1.058-5 3.176-5 6.386V21h10m7-5v6m-3-3h6m-10.5-7a5.5 5.5 0 100-11 5.5 5.5 0 000 11z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
생성됨 — <strong>{{ formatDate(member?.createdAt) }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">참여도</h3>
|
||||||
|
<p class="mt-5 text-sm leading-6 text-[#8a95a5]">
|
||||||
|
댓글 작성 {{ member?.commentCount || 0 }}개
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="admin-member-form__content space-y-8 xl:col-span-2">
|
||||||
|
<form class="admin-member-form__card rounded-xl border border-line bg-white p-5 md:p-6" @submit.prevent="saveMember">
|
||||||
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
|
<label class="admin-member-form__field block">
|
||||||
|
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
|
||||||
|
<input v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
|
||||||
|
</label>
|
||||||
|
<label class="admin-member-form__field block">
|
||||||
|
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이메일</span>
|
||||||
|
<input v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="admin-member-form__field mt-5 block">
|
||||||
|
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
|
||||||
|
<input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="admin-member-form__field mt-5 block">
|
||||||
|
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">
|
||||||
|
노트 <span class="font-normal text-[#657080]">(멤버에게 보이지 않음)</span>
|
||||||
|
</span>
|
||||||
|
<textarea v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-transparent bg-[#eef1f4] px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
|
||||||
|
<span class="admin-member-form__count mt-2 block text-sm text-[#8a95a5]">
|
||||||
|
최대 500자. 현재 {{ noteLength }}자
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
|
||||||
|
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
|
||||||
|
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
|
||||||
|
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section v-if="!isNewMember" class="admin-member-form__activity">
|
||||||
|
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
|
||||||
|
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
|
||||||
|
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 border-b border-line py-5 text-sm last:border-b-0">
|
||||||
|
<span class="flex items-center gap-3 text-[#3f4650]">
|
||||||
|
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 12h10.31m-3.076-3.076L14.31 12l-3.076 3.077" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M4.998 16.308a7.69 7.69 0 003.733 3.182 7.238 7.238 0 004.8.189 7.608 7.608 0 003.949-2.88A8.283 8.283 0 0018.998 12c0-1.73-.533-3.414-1.518-4.798a7.607 7.607 0 00-3.949-2.88 7.237 7.237 0 00-4.8.188 7.69 7.69 0 00-3.733 3.182" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
로그인
|
||||||
|
</span>
|
||||||
|
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.lastSeenAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 py-5 text-sm">
|
||||||
|
<span class="flex items-center gap-3 text-[#3f4650]">
|
||||||
|
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M11.246 12.144a4.242 4.242 0 100-8.484 4.242 4.242 0 000 8.484zM4 18.761a8.484 8.484 0 0110.5-3.42" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M17.54 16.077V23m-3.463-3.46H21" stroke="#30CF43" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
가입
|
||||||
|
</span>
|
||||||
|
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminUnsavedChangesModal
|
||||||
|
:open="isUnsavedModalOpen"
|
||||||
|
@stay="stayOnUnsavedPage"
|
||||||
|
@leave="leaveUnsavedPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="passwordModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||||
|
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||||
|
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||||
|
<h2 class="text-xl font-semibold">비밀번호 변경</h2>
|
||||||
|
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closePasswordModal">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="grid gap-4 px-6 py-5">
|
||||||
|
<p class="text-sm leading-6 text-[#657080]">
|
||||||
|
이메일 전송이 불가능한 상황을 대비해 관리자가 직접 새 비밀번호를 설정합니다.
|
||||||
|
</p>
|
||||||
|
<label class="grid gap-2 text-sm font-semibold">
|
||||||
|
새 비밀번호
|
||||||
|
<input v-model="passwordForm.password" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2 text-sm font-semibold">
|
||||||
|
새 비밀번호 확인
|
||||||
|
<input v-model="passwordForm.passwordConfirm" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||||
|
</div>
|
||||||
|
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||||
|
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closePasswordModal">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUpdatingPassword" @click="updateMemberPassword">
|
||||||
|
{{ isUpdatingPassword ? '변경 중' : '변경' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="deleteModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||||
|
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||||
|
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||||
|
<h2 class="text-xl font-semibold">멤버 삭제</h2>
|
||||||
|
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeDeleteModal">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="grid gap-4 px-6 py-5">
|
||||||
|
<p class="text-sm leading-6 text-[#657080]">
|
||||||
|
삭제하면 멤버 계정과 작성 댓글이 함께 삭제됩니다. 계속하려면 아래에 <strong class="text-[#15171a]">{{ form.email }}</strong> 을 입력해 주세요.
|
||||||
|
</p>
|
||||||
|
<input v-model="deleteForm.confirmText" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" autocomplete="off">
|
||||||
|
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||||
|
</div>
|
||||||
|
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||||
|
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeDeleteModal">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isDeletingMember" @click="deleteMember">
|
||||||
|
{{ isDeletingMember ? '삭제 중' : '삭제' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
214
components/admin/AdminNavPrimaryBranch.vue
Normal file
214
components/admin/AdminNavPrimaryBranch.vue
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<script setup>
|
||||||
|
import AdminNavPrimaryBranch from './AdminNavPrimaryBranch.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** buildNavigationEditorTree 결과 */
|
||||||
|
wraps: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
/** 들여쓰기 단계 */
|
||||||
|
depth: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
/** 루트면 `'root'`, 아니면 부모 항목 id */
|
||||||
|
parentKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'root'
|
||||||
|
},
|
||||||
|
/** 드래그 중인 항목 id */
|
||||||
|
draggingId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 드롭 대상 위에 올린 항목 id */
|
||||||
|
dragOverId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'drag-start',
|
||||||
|
'drag-over',
|
||||||
|
'drag-end',
|
||||||
|
'drop',
|
||||||
|
'add-child',
|
||||||
|
'remove'
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
|
||||||
|
* @param {DragEvent} event - 이벤트
|
||||||
|
* @returns {boolean} true면 드래그를 막는다
|
||||||
|
*/
|
||||||
|
const shouldBlockRowDrag = (event) => {
|
||||||
|
const el = event.target
|
||||||
|
if (!el || typeof el.closest !== 'function') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return Boolean(el.closest('input, button, textarea, select, a'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 시작
|
||||||
|
* @param {DragEvent} event - 이벤트
|
||||||
|
* @param {string} itemId - 항목 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onDragStart = (event, itemId) => {
|
||||||
|
if (shouldBlockRowDrag(event)) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!event.dataTransfer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('drag-start', { parentKey: props.parentKey, itemId })
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 오버
|
||||||
|
* @param {DragEvent} event - 이벤트
|
||||||
|
* @param {string} itemId - 항목 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onDragOver = (event, itemId) => {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('drag-over', itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드롭
|
||||||
|
* @param {DragEvent} event - 이벤트
|
||||||
|
* @param {string} itemId - 대상 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onDrop = (event, itemId) => {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('drop', { parentKey: props.parentKey, targetId: itemId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 종료
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onDragEnd = () => {
|
||||||
|
emit('drag-end')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 행 하이라이트 클래스(태그 관리 메인 태그 테이블과 동일 톤)
|
||||||
|
* @param {string} itemId - 항목 id
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const rowStateClass = (itemId) => {
|
||||||
|
const id = String(itemId)
|
||||||
|
if (props.dragOverId === id) {
|
||||||
|
return 'bg-[#f9f9f7]'
|
||||||
|
}
|
||||||
|
if (props.draggingId === id) {
|
||||||
|
return 'opacity-50'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-nav-primary-branch" :class="depth ? 'mt-2 border-l border-line pl-3' : ''">
|
||||||
|
<div class="admin-nav-primary-branch__shell overflow-hidden rounded border border-line">
|
||||||
|
<table class="admin-nav-primary-branch__table w-full border-collapse text-left text-sm">
|
||||||
|
<thead v-if="depth === 0" class="admin-nav-primary-branch__head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||||
|
<tr>
|
||||||
|
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||||
|
라벨
|
||||||
|
</th>
|
||||||
|
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||||
|
URL
|
||||||
|
</th>
|
||||||
|
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||||
|
관리
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="admin-nav-primary-branch__body divide-y divide-line bg-white">
|
||||||
|
<template v-for="(wrap, index) in wraps" :key="wrap.item.id">
|
||||||
|
<tr
|
||||||
|
class="admin-nav-primary-branch__row cursor-move"
|
||||||
|
:class="rowStateClass(wrap.item.id)"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, wrap.item.id)"
|
||||||
|
@dragover="onDragOver($event, wrap.item.id)"
|
||||||
|
@drop="onDrop($event, wrap.item.id)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle text-muted">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</td>
|
||||||
|
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
|
||||||
|
<input
|
||||||
|
v-model="wrap.item.label"
|
||||||
|
class="admin-nav-primary-branch__label w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||||
|
type="text"
|
||||||
|
placeholder="라벨"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
|
||||||
|
<input
|
||||||
|
v-model="wrap.item.url"
|
||||||
|
class="admin-nav-primary-branch__url w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
|
||||||
|
type="text"
|
||||||
|
placeholder="URL (# 또는 /경로)"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
|
||||||
|
<div class="admin-nav-primary-branch__actions flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
class="admin-nav-primary-branch__add-child rounded border border-line px-3 py-1.5 text-xs font-semibold"
|
||||||
|
type="button"
|
||||||
|
@click="emit('add-child', wrap.item.id)"
|
||||||
|
>
|
||||||
|
하위
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="admin-nav-primary-branch__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
|
||||||
|
type="button"
|
||||||
|
@click="emit('remove', wrap.item.id)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="wrap.children.length" class="admin-nav-primary-branch__nest bg-white">
|
||||||
|
<td class="p-0" colspan="4">
|
||||||
|
<div class="admin-nav-primary-branch__nest-inner border-t border-line bg-[#fafaf8] px-2 py-3">
|
||||||
|
<AdminNavPrimaryBranch
|
||||||
|
:wraps="wrap.children"
|
||||||
|
:depth="depth + 1"
|
||||||
|
:parent-key="String(wrap.item.id)"
|
||||||
|
:dragging-id="draggingId"
|
||||||
|
:drag-over-id="dragOverId"
|
||||||
|
@drag-start="emit('drag-start', $event)"
|
||||||
|
@drag-over="emit('drag-over', $event)"
|
||||||
|
@drag-end="emit('drag-end')"
|
||||||
|
@drop="emit('drop', $event)"
|
||||||
|
@add-child="emit('add-child', $event)"
|
||||||
|
@remove="emit('remove', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
278
components/admin/AdminPageForm.vue
Normal file
278
components/admin/AdminPageForm.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
initialPage: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
submitLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '저장'
|
||||||
|
},
|
||||||
|
saving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
|
const slugTouched = ref(Boolean(props.initialPage.slug))
|
||||||
|
const blockEditor = ref(null)
|
||||||
|
const mediaItems = ref([])
|
||||||
|
const isMediaPickerOpen = ref(false)
|
||||||
|
const isLoadingMedia = ref(false)
|
||||||
|
const isUploadingFeaturedImage = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: props.initialPage.title || '',
|
||||||
|
slug: props.initialPage.slug || '',
|
||||||
|
content: props.initialPage.content || '',
|
||||||
|
featuredImage: props.initialPage.featuredImage || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자열을 URL 슬러그로 변환
|
||||||
|
* @param {string} value - 원본 문자열
|
||||||
|
* @returns {string} 슬러그
|
||||||
|
*/
|
||||||
|
const toSlug = (value) => value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9가-힣\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
watch(() => form.title, (title) => {
|
||||||
|
if (!slugTouched.value) {
|
||||||
|
form.slug = toSlug(title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 슬러그 직접 입력 상태 표시
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const touchSlug = () => {
|
||||||
|
slugTouched.value = true
|
||||||
|
form.slug = toSlug(form.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 라이브러리 목록 조회
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const fetchMediaItems = async () => {
|
||||||
|
isLoadingMedia.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
mediaItems.value = await $fetch('/admin/api/media')
|
||||||
|
} finally {
|
||||||
|
isLoadingMedia.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 선택 창 열기
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const openMediaPicker = async () => {
|
||||||
|
isMediaPickerOpen.value = true
|
||||||
|
await fetchMediaItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 선택 창 닫기
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeMediaPicker = () => {
|
||||||
|
isMediaPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 선택
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const selectFeaturedImage = (item) => {
|
||||||
|
form.featuredImage = item.url
|
||||||
|
closeMediaPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 삭제
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const removeFeaturedImage = () => {
|
||||||
|
form.featuredImage = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 이미지 파일 업로드
|
||||||
|
* @param {Event} event - 파일 입력 이벤트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const uploadFeaturedImage = async (event) => {
|
||||||
|
const files = event.target.files
|
||||||
|
|
||||||
|
if (!files?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('files', files[0])
|
||||||
|
isUploadingFeaturedImage.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await $fetch('/admin/api/uploads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
form.featuredImage = result.files?.[0]?.url || ''
|
||||||
|
} finally {
|
||||||
|
event.target.value = ''
|
||||||
|
isUploadingFeaturedImage.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목 입력 후 본문 에디터로 이동
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const focusContentEditor = () => {
|
||||||
|
blockEditor.value?.focusFirstBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 입력값 제출
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const submitPage = () => {
|
||||||
|
emit('submit', {
|
||||||
|
title: form.title.trim(),
|
||||||
|
slug: toSlug(form.slug || form.title),
|
||||||
|
content: form.content,
|
||||||
|
featuredImage: form.featuredImage.trim() || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="admin-page-form grid gap-6" @submit.prevent="submitPage">
|
||||||
|
<div class="admin-page-form__main grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||||
|
<section class="admin-page-form__content grid gap-4">
|
||||||
|
<input
|
||||||
|
v-model="form.title"
|
||||||
|
class="admin-page-form__title-input border-0 bg-transparent px-0 py-2 text-5xl font-semibold leading-tight text-ink outline-none placeholder:text-soft"
|
||||||
|
type="text"
|
||||||
|
placeholder="페이지 제목"
|
||||||
|
required
|
||||||
|
@keydown.enter.prevent="focusContentEditor"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="admin-page-form__field grid gap-2 text-sm">
|
||||||
|
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="admin-page-form__settings grid content-start gap-4">
|
||||||
|
<label class="admin-page-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-page-form__label font-medium">슬러그</span>
|
||||||
|
<input
|
||||||
|
v-model="form.slug"
|
||||||
|
class="admin-page-form__input rounded border border-line bg-white px-3 py-2"
|
||||||
|
type="text"
|
||||||
|
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||||
|
required
|
||||||
|
@input="touchSlug"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="admin-page-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-page-form__label font-medium">대표 이미지</span>
|
||||||
|
<figure v-if="form.featuredImage" class="admin-page-form__featured overflow-hidden rounded border border-line bg-white">
|
||||||
|
<img class="admin-page-form__featured-image aspect-[4/3] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
|
||||||
|
<figcaption class="admin-page-form__featured-actions grid gap-2 p-3">
|
||||||
|
<p class="admin-page-form__featured-url break-all text-xs text-muted">
|
||||||
|
{{ form.featuredImage }}
|
||||||
|
</p>
|
||||||
|
<div class="admin-page-form__featured-buttons flex flex-wrap gap-2">
|
||||||
|
<button class="admin-page-form__featured-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker">
|
||||||
|
변경
|
||||||
|
</button>
|
||||||
|
<label class="admin-page-form__featured-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
|
||||||
|
새 업로드
|
||||||
|
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||||
|
</label>
|
||||||
|
<button class="admin-page-form__featured-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeFeaturedImage">
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
<div v-else class="admin-page-form__featured-empty grid gap-2 rounded border border-dashed border-line bg-white p-4">
|
||||||
|
<button class="admin-page-form__featured-select rounded border border-line px-3 py-2 text-sm font-semibold" type="button" @click="openMediaPicker">
|
||||||
|
미디어에서 선택
|
||||||
|
</button>
|
||||||
|
<label class="admin-page-form__featured-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white">
|
||||||
|
{{ isUploadingFeaturedImage ? '업로드 중' : '새 이미지 업로드' }}
|
||||||
|
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-page-form__actions flex justify-end gap-3 border-t border-line pt-5">
|
||||||
|
<NuxtLink class="admin-page-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/pages">
|
||||||
|
취소
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
class="admin-page-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
type="submit"
|
||||||
|
:disabled="saving"
|
||||||
|
>
|
||||||
|
{{ saving ? '저장 중' : submitLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isMediaPickerOpen"
|
||||||
|
class="admin-page-form__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="closeMediaPicker"
|
||||||
|
>
|
||||||
|
<section class="admin-page-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
|
||||||
|
<div class="admin-page-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
||||||
|
<h2 class="admin-page-form__media-picker-title text-lg font-semibold">
|
||||||
|
대표 이미지 선택
|
||||||
|
</h2>
|
||||||
|
<button class="admin-page-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-page-form__media-picker-body max-h-[62vh] overflow-y-auto p-5">
|
||||||
|
<p v-if="isLoadingMedia" class="admin-page-form__media-picker-loading text-sm text-muted">
|
||||||
|
미디어를 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
<div v-else-if="mediaItems.length" class="admin-page-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||||
|
<button
|
||||||
|
v-for="item in mediaItems"
|
||||||
|
:key="item.url"
|
||||||
|
class="admin-page-form__media-picker-item overflow-hidden border border-line bg-white text-left"
|
||||||
|
type="button"
|
||||||
|
@click="selectFeaturedImage(item)"
|
||||||
|
>
|
||||||
|
<img class="admin-page-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||||
|
<span class="admin-page-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="admin-page-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
|
||||||
|
선택할 미디어가 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
1333
components/admin/AdminPostForm.vue
Normal file
1333
components/admin/AdminPostForm.vue
Normal file
File diff suppressed because it is too large
Load Diff
137
components/admin/AdminTagForm.vue
Normal file
137
components/admin/AdminTagForm.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
initialTag: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
submitLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '저장'
|
||||||
|
},
|
||||||
|
saving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
|
const slugTouched = ref(Boolean(props.initialTag.slug))
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: props.initialTag.name || '',
|
||||||
|
slug: props.initialTag.slug || '',
|
||||||
|
description: props.initialTag.description || '',
|
||||||
|
color: props.initialTag.color || '#15171a'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자열을 URL 슬러그로 변환
|
||||||
|
* @param {string} value - 원본 문자열
|
||||||
|
* @returns {string} 슬러그
|
||||||
|
*/
|
||||||
|
const toSlug = (value) => value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9가-힣\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
watch(() => form.name, (name) => {
|
||||||
|
if (!slugTouched.value) {
|
||||||
|
form.slug = toSlug(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 슬러그 직접 입력 상태 표시
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const touchSlug = () => {
|
||||||
|
slugTouched.value = true
|
||||||
|
form.slug = toSlug(form.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 입력값 제출
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const submitTag = () => {
|
||||||
|
emit('submit', {
|
||||||
|
name: form.name.trim(),
|
||||||
|
slug: toSlug(form.slug || form.name),
|
||||||
|
description: form.description.trim(),
|
||||||
|
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||||
|
color: form.color,
|
||||||
|
tagType: props.initialTag.tagType || 'general'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="admin-tag-form grid gap-6" @submit.prevent="submitTag">
|
||||||
|
<section class="admin-tag-form__panel grid gap-5 border border-line bg-white p-5">
|
||||||
|
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-tag-form__label font-medium">이름</span>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-tag-form__label font-medium">슬러그</span>
|
||||||
|
<input
|
||||||
|
v-model="form.slug"
|
||||||
|
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
|
||||||
|
type="text"
|
||||||
|
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||||
|
required
|
||||||
|
@input="touchSlug"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-tag-form__label font-medium">설명</span>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
class="admin-tag-form__textarea min-h-28 rounded border border-line bg-white px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-tag-form__label font-medium">색상 코드</span>
|
||||||
|
<span class="admin-tag-form__color-row flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
v-model="form.color"
|
||||||
|
class="admin-tag-form__color h-10 w-12 rounded border border-line bg-white p-1"
|
||||||
|
type="color"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.color"
|
||||||
|
class="admin-tag-form__input min-w-0 flex-1 rounded border border-line bg-white px-3 py-2"
|
||||||
|
type="text"
|
||||||
|
pattern="#[0-9a-fA-F]{6}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-tag-form__actions flex justify-end gap-3">
|
||||||
|
<NuxtLink class="admin-tag-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/tags">
|
||||||
|
취소
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
class="admin-tag-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
type="submit"
|
||||||
|
:disabled="saving"
|
||||||
|
>
|
||||||
|
{{ saving ? '저장 중' : submitLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
61
components/admin/AdminUnsavedChangesModal.vue
Normal file
61
components/admin/AdminUnsavedChangesModal.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
open: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['stay', 'leave'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="admin-unsaved-modal fixed inset-0 z-[100] flex items-start justify-center bg-black/40 px-5 pb-8 pt-10"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="admin-unsaved-modal-title"
|
||||||
|
>
|
||||||
|
<div class="admin-unsaved-modal__content relative w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-2xl">
|
||||||
|
<header class="admin-unsaved-modal__header border-b border-[#e3e6e8] px-8 py-6">
|
||||||
|
<h1 id="admin-unsaved-modal-title" class="admin-unsaved-modal__title text-xl font-semibold tracking-[-0.01em]">
|
||||||
|
이 페이지를 떠날까요?
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<button
|
||||||
|
class="admin-unsaved-modal__close absolute right-5 top-5 grid size-8 place-items-center rounded-md text-[#4d5663] transition hover:bg-[#eff1f2] hover:text-black"
|
||||||
|
type="button"
|
||||||
|
title="닫기"
|
||||||
|
aria-label="닫기"
|
||||||
|
@click="$emit('stay')"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="admin-unsaved-modal__body space-y-3 px-8 py-7 text-sm leading-6 text-[#4d5663]">
|
||||||
|
<p>저장하지 않은 변경사항이 있습니다.</p>
|
||||||
|
<p>떠나기 전에 저장해 주세요.</p>
|
||||||
|
</div>
|
||||||
|
<footer class="admin-unsaved-modal__footer flex justify-end gap-3 border-t border-[#e3e6e8] px-8 py-5">
|
||||||
|
<button
|
||||||
|
class="admin-unsaved-modal__stay h-10 rounded-md border border-[#d7dce0] bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2]"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('stay')"
|
||||||
|
>
|
||||||
|
머무르기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="admin-unsaved-modal__leave h-10 rounded-md bg-[#e5484d] px-4 text-sm font-semibold text-white transition hover:bg-[#d21a26]"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('leave')"
|
||||||
|
>
|
||||||
|
나가기
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
106
components/auth/AuthPasswordVisibilityToggle.vue
Normal file
106
components/auth/AuthPasswordVisibilityToggle.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 비밀번호 필드 표시/숨김 토글(Material 스타일 눈 아이콘 SVG)
|
||||||
|
*/
|
||||||
|
const props = defineProps({
|
||||||
|
/** 비밀번호를 평문으로 표시할 때 true */
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 스크린 리더용 필드 이름(예: 비밀번호 확인)
|
||||||
|
*/
|
||||||
|
fieldName: {
|
||||||
|
type: String,
|
||||||
|
default: '비밀번호'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근성용 레이블 문자열
|
||||||
|
* @param {'show' | 'hide'} kind - 보기 또는 숨기기
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const labelFor = (kind) => {
|
||||||
|
if (kind === 'show') {
|
||||||
|
return `${props.fieldName} 보기`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${props.fieldName} 숨기기`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시 상태를 반전한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggle = () => {
|
||||||
|
emit('update:modelValue', !props.modelValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="auth-password-visibility-toggle"
|
||||||
|
type="button"
|
||||||
|
:aria-label="modelValue ? labelFor('hide') : labelFor('show')"
|
||||||
|
:aria-pressed="modelValue"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="!modelValue"
|
||||||
|
class="auth-password-visibility-toggle__icon"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="auth-password-visibility-toggle__icon"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 22 19.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-password-visibility-toggle {
|
||||||
|
display: flex;
|
||||||
|
height: 2.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 0.625rem;
|
||||||
|
padding-right: 0.625rem;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #9ba3af;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-password-visibility-toggle:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-password-visibility-toggle:focus-visible {
|
||||||
|
outline: 2px solid rgba(47, 111, 235, 0.55);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-password-visibility-toggle__icon {
|
||||||
|
display: block;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
546
components/comments/PostComments.vue
Normal file
546
components/comments/PostComments.vue
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const comments = ref([])
|
||||||
|
const member = ref(null)
|
||||||
|
const loadingComments = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submittingReplyId = ref('')
|
||||||
|
const likingCommentIds = ref([])
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const replyErrorMessage = ref('')
|
||||||
|
const newCommentBody = ref('')
|
||||||
|
const replyBody = ref('')
|
||||||
|
const activeReplyTargetId = ref('')
|
||||||
|
const sortOption = ref('best')
|
||||||
|
const brokenAvatarCommentIds = ref([])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 댓글 시간을 상대 시간 형식으로 변환한다.
|
||||||
|
* @param {string} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 표시용 문자열
|
||||||
|
*/
|
||||||
|
const formatCommentDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const diffMs = now - date.getTime()
|
||||||
|
|
||||||
|
if (diffMs < 60 * 1000) {
|
||||||
|
return '방금 전'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < 60 * 60 * 1000) {
|
||||||
|
return `${Math.max(1, Math.floor(diffMs / (60 * 1000)))}분 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < 24 * 60 * 60 * 1000) {
|
||||||
|
return `${Math.max(1, Math.floor(diffMs / (60 * 60 * 1000)))}시간 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameYear = date.getFullYear() === new Date(now).getFullYear()
|
||||||
|
return date.toLocaleDateString('ko-KR', sameYear
|
||||||
|
? { month: 'short', day: 'numeric' }
|
||||||
|
: { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 댓글 작성자 아바타 이니셜을 생성한다.
|
||||||
|
* @param {{ username?: string, email?: string }} user - 작성자 정보
|
||||||
|
* @returns {string} 아바타 이니셜
|
||||||
|
*/
|
||||||
|
const getAvatarInitials = (user) => {
|
||||||
|
const baseText = String(user?.username || user?.email || '').trim()
|
||||||
|
if (!baseText) {
|
||||||
|
return '@'
|
||||||
|
}
|
||||||
|
const tokens = baseText.split(/\s+/g).filter(Boolean)
|
||||||
|
if (tokens.length >= 2) {
|
||||||
|
return `${tokens[0].slice(0, 1)}${tokens[1].slice(0, 1)}`.toUpperCase()
|
||||||
|
}
|
||||||
|
return baseText.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 댓글 작성 시각 숫자값 반환
|
||||||
|
* @param {string} value - ISO 날짜 문자열
|
||||||
|
* @returns {number} 시간 숫자값
|
||||||
|
*/
|
||||||
|
const toTimeValue = (value) => {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return date.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아바타 이미지 깨짐 여부 확인
|
||||||
|
* @param {string} commentId - 댓글 ID
|
||||||
|
* @returns {boolean} 깨짐 여부
|
||||||
|
*/
|
||||||
|
const isAvatarBroken = (commentId) => brokenAvatarCommentIds.value.includes(commentId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아바타 이미지 로드 실패를 기록한다.
|
||||||
|
* @param {string} commentId - 댓글 ID
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const markAvatarBroken = (commentId) => {
|
||||||
|
if (isAvatarBroken(commentId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
brokenAvatarCommentIds.value = [...brokenAvatarCommentIds.value, commentId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 세션을 조회한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const fetchMember = async () => {
|
||||||
|
try {
|
||||||
|
member.value = await $fetch('/api/auth/me')
|
||||||
|
} catch {
|
||||||
|
member.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 댓글 목록을 조회한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const fetchComments = async () => {
|
||||||
|
loadingComments.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch(`/api/posts/${props.slug}/comments`)
|
||||||
|
comments.value = response.comments || []
|
||||||
|
} catch (error) {
|
||||||
|
comments.value = []
|
||||||
|
errorMessage.value = error?.data?.message || '댓글을 불러오지 못했습니다.'
|
||||||
|
} finally {
|
||||||
|
loadingComments.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 루트 댓글을 작성한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const submitComment = async () => {
|
||||||
|
const body = newCommentBody.value.trim()
|
||||||
|
if (!body || submitting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/posts/${props.slug}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
body
|
||||||
|
}
|
||||||
|
})
|
||||||
|
newCommentBody.value = ''
|
||||||
|
await fetchComments()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 답글 작성 UI를 연다.
|
||||||
|
* @param {string} commentId - 대상 댓글 ID
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openReplyForm = (commentId) => {
|
||||||
|
activeReplyTargetId.value = commentId
|
||||||
|
replyBody.value = ''
|
||||||
|
replyErrorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 답글 작성 UI를 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeReplyForm = () => {
|
||||||
|
activeReplyTargetId.value = ''
|
||||||
|
replyBody.value = ''
|
||||||
|
replyErrorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대댓글을 작성한다.
|
||||||
|
* @param {string} parentId - 부모 댓글 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const submitReply = async (parentId) => {
|
||||||
|
const body = replyBody.value.trim()
|
||||||
|
if (!body || submittingReplyId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submittingReplyId.value = parentId
|
||||||
|
replyErrorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/posts/${props.slug}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
body,
|
||||||
|
parentId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
closeReplyForm()
|
||||||
|
await fetchComments()
|
||||||
|
} catch (error) {
|
||||||
|
replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
submittingReplyId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 댓글의 좋아요 요청 진행 여부
|
||||||
|
* @param {string} commentId - 댓글 ID
|
||||||
|
* @returns {boolean} 진행 여부
|
||||||
|
*/
|
||||||
|
const isLikingComment = (commentId) => likingCommentIds.value.includes(commentId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 댓글 좋아요를 토글한다.
|
||||||
|
* @param {string} commentId - 댓글 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const toggleLike = async (commentId) => {
|
||||||
|
if (!member.value || isLikingComment(commentId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
likingCommentIds.value = [...likingCommentIds.value, commentId]
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await $fetch(`/api/posts/${props.slug}/comments/${commentId}/like`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
comments.value = comments.value.map((item) => {
|
||||||
|
if (item.id !== commentId) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
likeCount: Number(result.likeCount || 0),
|
||||||
|
likedByMe: Boolean(result.liked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootComments = computed(() => comments.value.filter((item) => !item.parentId))
|
||||||
|
const sortedRootComments = computed(() => {
|
||||||
|
const copied = [...rootComments.value]
|
||||||
|
|
||||||
|
if (sortOption.value === 'latest') {
|
||||||
|
return copied.sort((left, right) => toTimeValue(right.createdAt) - toTimeValue(left.createdAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOption.value === 'oldest') {
|
||||||
|
return copied.sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
return copied.sort((left, right) => {
|
||||||
|
const likeDiff = Number(right.likeCount || 0) - Number(left.likeCount || 0)
|
||||||
|
if (likeDiff !== 0) {
|
||||||
|
return likeDiff
|
||||||
|
}
|
||||||
|
return toTimeValue(left.createdAt) - toTimeValue(right.createdAt)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const repliesByParent = computed(() => {
|
||||||
|
/** @type {Record<string, Array<any>>} */
|
||||||
|
const grouped = {}
|
||||||
|
|
||||||
|
for (const item of comments.value) {
|
||||||
|
if (!item.parentId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!grouped[item.parentId]) {
|
||||||
|
grouped[item.parentId] = []
|
||||||
|
}
|
||||||
|
grouped[item.parentId].push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const parentId of Object.keys(grouped)) {
|
||||||
|
grouped[parentId] = grouped[parentId].sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchMember(), fetchComments()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="post-comments text-sm">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<p class="font-medium"><span class="site-muted">{{ comments.length }}</span> Comments</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
|
||||||
|
<label for="comment-sort">Sort by:</label>
|
||||||
|
<select
|
||||||
|
id="comment-sort"
|
||||||
|
v-model="sortOption"
|
||||||
|
class="rounded-md border border-[var(--site-line)] bg-transparent px-2 py-1 text-xs font-semibold text-[var(--site-ink)] outline-none"
|
||||||
|
>
|
||||||
|
<option value="best">Best</option>
|
||||||
|
<option value="latest">Latest</option>
|
||||||
|
<option value="oldest">Oldest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div v-if="member" class="rounded-[10px] p-3">
|
||||||
|
<p class="mb-2 text-xs site-muted">
|
||||||
|
{{ member.username || member.email }} 님으로 댓글 작성
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
v-model="newCommentBody"
|
||||||
|
rows="4"
|
||||||
|
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
|
||||||
|
placeholder="댓글을 입력해 주세요."
|
||||||
|
/>
|
||||||
|
<div class="mt-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="submitComment"
|
||||||
|
>
|
||||||
|
{{ submitting ? '등록 중...' : '댓글 등록' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3">
|
||||||
|
<p class="site-muted">
|
||||||
|
댓글은 로그인한 회원만 작성할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<NuxtLink to="/signin" class="mt-2 inline-flex rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs font-semibold hover:opacity-80">
|
||||||
|
로그인하러 가기
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="mt-3 text-xs text-red-500">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<p v-if="loadingComments" class="text-xs site-muted">
|
||||||
|
댓글을 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else-if="sortedRootComments.length > 0" class="flex flex-col divide-y divide-[var(--site-line)]">
|
||||||
|
<li
|
||||||
|
v-for="comment in sortedRootComments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="py-4"
|
||||||
|
>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex w-8 flex-none flex-col items-center">
|
||||||
|
<div class="h-8 w-8 min-h-8 min-w-8 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||||
|
<img
|
||||||
|
v-if="comment.user.avatarUrl && !isAvatarBroken(comment.id)"
|
||||||
|
:src="comment.user.avatarUrl"
|
||||||
|
:alt="`${comment.user.username} 아바타`"
|
||||||
|
class="block h-full w-full object-cover"
|
||||||
|
@error="markAvatarBroken(comment.id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
|
||||||
|
>
|
||||||
|
{{ getAvatarInitials(comment.user) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<strong class="text-sm">{{ comment.user.username }}</strong>
|
||||||
|
<span class="text-xs site-muted">{{ formatCommentDate(comment.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 whitespace-pre-line leading-relaxed">
|
||||||
|
{{ comment.body }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-center gap-3 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
|
||||||
|
:disabled="!member || isLikingComment(comment.id)"
|
||||||
|
@click="toggleLike(comment.id)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
|
||||||
|
:fill="comment.likedByMe ? 'currentColor' : 'none'"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ comment.likeCount || 0 }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="member"
|
||||||
|
type="button"
|
||||||
|
class="group inline-flex items-center gap-1 site-muted hover:opacity-75"
|
||||||
|
@click="openReplyForm(comment.id)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m1.5 7 4.7-4.2v2.6c4.7 0 8 2 8.3 6.5-1.5-2.3-3.3-3.1-8.3-3.1v2.4L1.5 7Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
답글
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
|
||||||
|
<textarea
|
||||||
|
v-model="replyBody"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
|
||||||
|
placeholder="답글을 입력해 주세요."
|
||||||
|
/>
|
||||||
|
<p v-if="replyErrorMessage" class="mt-2 text-xs text-red-500">
|
||||||
|
{{ replyErrorMessage }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 flex justify-end gap-2">
|
||||||
|
<button type="button" class="rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs" @click="closeReplyForm">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||||
|
:disabled="submittingReplyId === comment.id"
|
||||||
|
@click="submitReply(comment.id)"
|
||||||
|
>
|
||||||
|
{{ submittingReplyId === comment.id ? '등록 중...' : '답글 등록' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="repliesByParent[comment.id]?.length"
|
||||||
|
class="mt-3 ml-4 flex flex-col gap-3 border-l border-[var(--site-line)]/90 pl-4"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="reply in repliesByParent[comment.id]"
|
||||||
|
:key="reply.id"
|
||||||
|
class="relative rounded-[10px] bg-[var(--site-panel)] p-2.5"
|
||||||
|
>
|
||||||
|
<span class="absolute -left-4 top-5 h-px w-3 bg-[var(--site-line)]/90" />
|
||||||
|
<div class="flex gap-2.5">
|
||||||
|
<div class="h-8 w-8 min-h-8 min-w-8 flex-none overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]">
|
||||||
|
<img
|
||||||
|
v-if="reply.user.avatarUrl && !isAvatarBroken(reply.id)"
|
||||||
|
:src="reply.user.avatarUrl"
|
||||||
|
:alt="`${reply.user.username} 아바타`"
|
||||||
|
class="block h-full w-full object-cover"
|
||||||
|
@error="markAvatarBroken(reply.id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
|
||||||
|
>
|
||||||
|
{{ getAvatarInitials(reply.user) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<strong class="text-sm">{{ reply.user.username }}</strong>
|
||||||
|
<span class="text-xs site-muted">{{ formatCommentDate(reply.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 whitespace-pre-line leading-relaxed">
|
||||||
|
{{ reply.body }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-1.5 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
|
||||||
|
:disabled="!member || isLikingComment(reply.id)"
|
||||||
|
@click="toggleLike(reply.id)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
|
||||||
|
:fill="reply.likedByMe ? 'currentColor' : 'none'"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ reply.likeCount || 0 }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p v-else class="text-xs site-muted">
|
||||||
|
첫 댓글을 남겨보세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
524
components/content/ContentMarkdownRenderer.vue
Normal file
524
components/content/ContentMarkdownRenderer.vue
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||||
|
|
||||||
|
const activeLightboxImages = ref([])
|
||||||
|
const activeLightboxIndex = ref(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마크다운 블록을 생성
|
||||||
|
* @param {string} type - 블록 타입
|
||||||
|
* @param {string|Array<string>} text - 블록 텍스트
|
||||||
|
* @param {number|null} level - 제목 레벨
|
||||||
|
* @param {string} id - 블록 ID
|
||||||
|
* @param {Object} options - 추가 블록 옵션
|
||||||
|
* @returns {Object} 블록
|
||||||
|
*/
|
||||||
|
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
level,
|
||||||
|
url: options.url || '',
|
||||||
|
alt: options.alt || '',
|
||||||
|
title: options.title || '',
|
||||||
|
variant: options.variant || '',
|
||||||
|
ordered: options.ordered || false,
|
||||||
|
width: options.width || 'regular',
|
||||||
|
images: options.images || [],
|
||||||
|
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
|
||||||
|
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
|
||||||
|
calloutEmoji: options.calloutEmoji || '💡',
|
||||||
|
calloutBackground: options.calloutBackground || 'blue'
|
||||||
|
})
|
||||||
|
|
||||||
|
const calloutBackgroundOptions = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 선언부 옵션을 파싱
|
||||||
|
* @param {string} line - 콜아웃 선언 라인
|
||||||
|
* @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
|
||||||
|
*/
|
||||||
|
const parseCalloutOptions = (line) => {
|
||||||
|
const options = {
|
||||||
|
calloutEmojiEnabled: true,
|
||||||
|
calloutEmoji: '💡',
|
||||||
|
calloutBackground: 'blue'
|
||||||
|
}
|
||||||
|
const tokens = line.trim().split(/\s+/).slice(1)
|
||||||
|
|
||||||
|
tokens.forEach((token) => {
|
||||||
|
const [rawKey, ...rawValueParts] = token.split('=')
|
||||||
|
if (!rawKey || !rawValueParts.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = rawKey.toLowerCase()
|
||||||
|
const value = rawValueParts.join('=').trim()
|
||||||
|
|
||||||
|
if (key === 'emoji') {
|
||||||
|
if (!value || value === 'none') {
|
||||||
|
options.calloutEmojiEnabled = false
|
||||||
|
options.calloutEmoji = '💡'
|
||||||
|
} else {
|
||||||
|
options.calloutEmojiEnabled = true
|
||||||
|
options.calloutEmoji = value
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'bg' && calloutBackgroundOptions.includes(value)) {
|
||||||
|
options.calloutBackground = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 마크다운 행을 이미지 데이터로 변환
|
||||||
|
* @param {string} line - 마크다운 행
|
||||||
|
* @returns {Object|null} 이미지 데이터
|
||||||
|
*/
|
||||||
|
const parseImageLine = (line) => {
|
||||||
|
const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
alt: match[1],
|
||||||
|
url: match[2],
|
||||||
|
width: match[3] || 'regular'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 닫힘 표식까지의 행 목록을 반환
|
||||||
|
* @param {Array<string>} lines - 전체 마크다운 행
|
||||||
|
* @param {number} startIndex - 본문 시작 인덱스
|
||||||
|
* @returns {{contentLines: Array<string>, nextIndex: number}} 블록 본문과 다음 인덱스
|
||||||
|
*/
|
||||||
|
const collectFencedLines = (lines, startIndex) => {
|
||||||
|
const contentLines = []
|
||||||
|
let index = startIndex
|
||||||
|
|
||||||
|
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||||
|
contentLines.push(lines[index])
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentLines,
|
||||||
|
nextIndex: index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 북마크 fenced 블록 본문에서 URL·제목·설명·썸네일을 파싱한다.
|
||||||
|
* @param {string} raw - fenced 내부 텍스트
|
||||||
|
* @returns {{url: string, title: string, description: string, thumbnail: string}} 북마크 메타
|
||||||
|
*/
|
||||||
|
const parseBookmarkMeta = (raw) => {
|
||||||
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||||
|
const meta = {
|
||||||
|
url: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
thumbnail: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const kv = line.match(/^(\w+)=(.*)$/)
|
||||||
|
|
||||||
|
if (kv) {
|
||||||
|
const key = kv[1].toLowerCase()
|
||||||
|
const val = kv[2].trim()
|
||||||
|
|
||||||
|
if (key === 'url') {
|
||||||
|
meta.url = val
|
||||||
|
} else if (key === 'title') {
|
||||||
|
meta.title = val
|
||||||
|
} else if (key === 'description' || key === 'desc') {
|
||||||
|
meta.description = val
|
||||||
|
} else if (key === 'thumbnail' || key === 'image') {
|
||||||
|
meta.thumbnail = val
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meta.url && /^https?:\/\//i.test(line)) {
|
||||||
|
meta.url = line
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.url && !meta.title) {
|
||||||
|
meta.title = line
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.url && meta.title && !meta.description) {
|
||||||
|
meta.description = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입(CTA) fenced 블록 본문에서 표시 문구를 파싱한다.
|
||||||
|
* @param {string} raw - fenced 내부 텍스트
|
||||||
|
* @returns {{title: string, description: string, button: string, placeholder: string}} CTA 메타
|
||||||
|
*/
|
||||||
|
const parseSignupMeta = (raw) => {
|
||||||
|
const meta = {
|
||||||
|
title: '뉴스레터에 가입하세요',
|
||||||
|
description: '새 글이 올라오면 받아보실 수 있어요.',
|
||||||
|
button: '구독하기',
|
||||||
|
placeholder: 'you@example.com'
|
||||||
|
}
|
||||||
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const kv = line.match(/^(\w+)=(.*)$/)
|
||||||
|
|
||||||
|
if (!kv) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = kv[1].toLowerCase()
|
||||||
|
const val = kv[2].trim()
|
||||||
|
|
||||||
|
if (key === 'title') {
|
||||||
|
meta.title = val
|
||||||
|
} else if (key === 'description' || key === 'desc') {
|
||||||
|
meta.description = val
|
||||||
|
} else if (key === 'button') {
|
||||||
|
meta.button = val
|
||||||
|
} else if (key === 'placeholder') {
|
||||||
|
meta.placeholder = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
||||||
|
* @param {string} markdown - 마크다운 문자열
|
||||||
|
* @returns {Array<Object>} 블록 목록
|
||||||
|
*/
|
||||||
|
const parseMarkdownBlocks = (markdown) => {
|
||||||
|
const lines = markdown.split('\n')
|
||||||
|
const blocks = []
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index]
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
|
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
|
||||||
|
blocks.push(createBlock('paragraph', '', null, `block-${blocks.length}`))
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedLine) {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine === '>>>') {
|
||||||
|
const contentLines = []
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
while (index < lines.length && lines[index].trim() !== '<<<') {
|
||||||
|
contentLines.push(lines[index])
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(createBlock('quote', contentLines.join('\n').trim(), null, `block-${blocks.length}`, { variant: 'alt' }))
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine === ':::bookmark') {
|
||||||
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||||
|
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
|
||||||
|
|
||||||
|
if (bookmarkMeta.url) {
|
||||||
|
blocks.push(createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }))
|
||||||
|
}
|
||||||
|
|
||||||
|
index = nextIndex
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine === ':::signup') {
|
||||||
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||||
|
const signupMeta = parseSignupMeta(contentLines.join('\n'))
|
||||||
|
blocks.push(createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }))
|
||||||
|
index = nextIndex
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine === ':::gallery') {
|
||||||
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||||
|
const images = []
|
||||||
|
|
||||||
|
contentLines.forEach((contentLine) => {
|
||||||
|
const image = parseImageLine(contentLine)
|
||||||
|
if (image) {
|
||||||
|
images.push(image)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images }))
|
||||||
|
index = nextIndex
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith(':::callout')) {
|
||||||
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||||
|
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
|
||||||
|
index = nextIndex
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith(':::toggle')) {
|
||||||
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||||
|
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
|
||||||
|
blocks.push(createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }))
|
||||||
|
index = nextIndex
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine === ':::embed') {
|
||||||
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||||
|
blocks.push(createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
|
||||||
|
index = nextIndex
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = parseImageLine(trimmedLine)
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
blocks.push(createBlock('image', '', null, `block-${blocks.length}`, image))
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith('```')) {
|
||||||
|
const codeLines = []
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
while (index < lines.length && !lines[index].trim().startsWith('```')) {
|
||||||
|
codeLines.push(lines[index])
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`))
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine === '---') {
|
||||||
|
blocks.push(createBlock('divider', '', null, `block-${blocks.length}`))
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/)
|
||||||
|
|
||||||
|
if (headingMatch) {
|
||||||
|
blocks.push(createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`))
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith('> ')) {
|
||||||
|
const quoteLines = []
|
||||||
|
|
||||||
|
while (index < lines.length && lines[index].trim().startsWith('>')) {
|
||||||
|
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(createBlock('quote', quoteLines.join('\n').trim(), null, `block-${blocks.length}`))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^- /.test(trimmedLine)) {
|
||||||
|
const items = []
|
||||||
|
|
||||||
|
while (index < lines.length && /^- /.test(lines[index].trim())) {
|
||||||
|
items.push(lines[index].trim().replace(/^- /, ''))
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(createBlock('list', items, null, `block-${blocks.length}`))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+\.\s+/.test(trimmedLine)) {
|
||||||
|
const items = []
|
||||||
|
|
||||||
|
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
|
||||||
|
items.push(lines[index].trim().replace(/^\d+\.\s+/, ''))
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(createBlock('list', items, null, `block-${blocks.length}`, { ordered: true }))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(createBlock('paragraph', trimmedLine, null, `block-${blocks.length}`))
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||||
|
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이트박스를 연다
|
||||||
|
* @param {Array<Object>} images - 이미지 목록
|
||||||
|
* @param {number} index - 시작 인덱스
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openLightbox = (images, index) => {
|
||||||
|
activeLightboxImages.value = images
|
||||||
|
activeLightboxIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이트박스를 닫는다
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeLightbox = () => {
|
||||||
|
activeLightboxImages.value = []
|
||||||
|
activeLightboxIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이트박스 이전 이미지로 이동
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showPreviousImage = () => {
|
||||||
|
activeLightboxIndex.value = activeLightboxIndex.value === 0
|
||||||
|
? activeLightboxImages.value.length - 1
|
||||||
|
: activeLightboxIndex.value - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이트박스 다음 이미지로 이동
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showNextImage = () => {
|
||||||
|
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="content-markdown-renderer">
|
||||||
|
<template v-for="block in blocks" :key="block.id">
|
||||||
|
<ProseHeading v-if="block.type === 'heading'" :level="block.level">
|
||||||
|
{{ block.text }}
|
||||||
|
</ProseHeading>
|
||||||
|
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
|
||||||
|
{{ block.text }}
|
||||||
|
</ProseBlockquote>
|
||||||
|
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
|
||||||
|
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
|
||||||
|
{{ item }}
|
||||||
|
</li>
|
||||||
|
</ProseList>
|
||||||
|
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
||||||
|
{{ block.alt }}
|
||||||
|
</ProseImage>
|
||||||
|
<ProseCallout
|
||||||
|
v-else-if="block.type === 'callout'"
|
||||||
|
:emoji-enabled="block.calloutEmojiEnabled"
|
||||||
|
:emoji="block.calloutEmoji"
|
||||||
|
:background="block.calloutBackground"
|
||||||
|
>
|
||||||
|
{{ block.text }}
|
||||||
|
</ProseCallout>
|
||||||
|
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
||||||
|
{{ block.text }}
|
||||||
|
</ProseToggle>
|
||||||
|
<ProseBookmark
|
||||||
|
v-else-if="block.type === 'bookmark' && block.meta.url"
|
||||||
|
:url="block.meta.url"
|
||||||
|
:title="block.meta.title"
|
||||||
|
:description="block.meta.description"
|
||||||
|
:thumbnail="block.meta.thumbnail"
|
||||||
|
/>
|
||||||
|
<ProseSignup
|
||||||
|
v-else-if="block.type === 'signup'"
|
||||||
|
:title="block.meta.title"
|
||||||
|
:description="block.meta.description"
|
||||||
|
:button-label="block.meta.button"
|
||||||
|
:placeholder="block.meta.placeholder"
|
||||||
|
/>
|
||||||
|
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
|
||||||
|
<div v-else-if="block.type === 'gallery'" class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||||
|
<button
|
||||||
|
v-for="(image, imageIndex) in block.images"
|
||||||
|
:key="`${block.id}-${image.url}`"
|
||||||
|
class="content-markdown-renderer__gallery-button overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||||
|
type="button"
|
||||||
|
@click="openLightbox(block.images, imageIndex)"
|
||||||
|
>
|
||||||
|
<img class="content-markdown-renderer__gallery-image aspect-[4/3] w-full object-cover transition-transform hover:scale-[1.02]" :src="image.url" :alt="image.alt">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
v-else-if="block.type === 'code'"
|
||||||
|
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
|
||||||
|
><code>{{ block.text }}</code></pre>
|
||||||
|
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
|
||||||
|
<p v-else class="content-markdown-renderer__paragraph my-5 text-[15px] leading-8 text-[var(--site-text)]">
|
||||||
|
{{ block.text }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="activeLightboxImage"
|
||||||
|
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="closeLightbox"
|
||||||
|
>
|
||||||
|
<button class="content-markdown-renderer__lightbox-close absolute right-5 top-5 rounded bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeLightbox">
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="activeLightboxImages.length > 1"
|
||||||
|
class="content-markdown-renderer__lightbox-prev absolute left-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
||||||
|
type="button"
|
||||||
|
@click="showPreviousImage"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<img class="content-markdown-renderer__lightbox-image max-h-[84vh] max-w-[92vw] object-contain" :src="activeLightboxImage.url" :alt="activeLightboxImage.alt">
|
||||||
|
<button
|
||||||
|
v-if="activeLightboxImages.length > 1"
|
||||||
|
class="content-markdown-renderer__lightbox-next absolute right-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
||||||
|
type="button"
|
||||||
|
@click="showNextImage"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
5
components/content/ContentRenderer.vue
Normal file
5
components/content/ContentRenderer.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<article class="content-renderer post-prose">
|
||||||
|
<slot />
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
5
components/content/ProseAudio.vue
Normal file
5
components/content/ProseAudio.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
components/content/ProseBlockquote.vue
Normal file
21
components/content/ProseBlockquote.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<blockquote
|
||||||
|
class="prose-blockquote my-8 text-[15px] leading-8 text-[var(--site-text)]"
|
||||||
|
:class="variant === 'alt'
|
||||||
|
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic'
|
||||||
|
: 'rounded-[10px] border-l-2 border-[var(--site-text)] bg-[var(--site-panel)] px-5 py-4 font-medium'"
|
||||||
|
>
|
||||||
|
<span class="whitespace-pre-line">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</blockquote>
|
||||||
|
</template>
|
||||||
112
components/content/ProseBookmark.vue
Normal file
112
components/content/ProseBookmark.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 북마크 카드에 표시할 호스트명을 반환한다.
|
||||||
|
* @returns {string} www 없는 호스트 또는 빈 문자열
|
||||||
|
*/
|
||||||
|
const displayHost = computed(() => {
|
||||||
|
try {
|
||||||
|
return new URL(props.url).hostname.replace(/^www\./, '')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 썸네일이 비었을 때 파비콘 보조 URL을 만든다.
|
||||||
|
* @returns {string} favicon 요청 URL
|
||||||
|
*/
|
||||||
|
const faviconUrl = computed(() => {
|
||||||
|
if (!displayHost.value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(displayHost.value)}&sz=128`
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제로 표시할 이미지 주소
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const imageSrc = computed(() => props.thumbnail || faviconUrl.value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시 제목(없으면 호스트·URL)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const displayTitle = computed(() => props.title || displayHost.value || props.url)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 링크로 열어도 되는 URL인지 확인한다.
|
||||||
|
* @returns {boolean} 허용 여부
|
||||||
|
*/
|
||||||
|
const isSafeBookmarkUrl = computed(() => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(props.url)
|
||||||
|
return ['http:', 'https:'].includes(parsedUrl.protocol)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a
|
||||||
|
v-if="isSafeBookmarkUrl"
|
||||||
|
class="prose-bookmark group prose-bookmark-card my-8 flex max-w-full flex-col overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] no-underline transition-[background-color,box-shadow] hover:bg-[color-mix(in_srgb,var(--site-panel)_86%,var(--site-text)_14%)] sm:flex-row"
|
||||||
|
:href="url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<div class="prose-bookmark__media relative h-36 w-full shrink-0 overflow-hidden bg-[color-mix(in_srgb,var(--site-line)_40%,var(--site-panel))] sm:h-auto sm:w-[min(44%,220px)] sm:min-h-[9rem]">
|
||||||
|
<img
|
||||||
|
v-if="imageSrc"
|
||||||
|
class="prose-bookmark__thumb h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||||
|
:src="imageSrc"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="prose-bookmark__body flex min-w-0 flex-1 flex-col justify-center gap-1 px-4 py-4 sm:px-5 sm:py-5">
|
||||||
|
<p v-if="displayHost" class="prose-bookmark__host text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--site-muted)]">
|
||||||
|
{{ displayHost }}
|
||||||
|
</p>
|
||||||
|
<p class="prose-bookmark__title text-[15px] font-semibold leading-snug text-[var(--site-text)]">
|
||||||
|
{{ displayTitle }}
|
||||||
|
</p>
|
||||||
|
<p v-if="description" class="prose-bookmark__desc line-clamp-2 text-sm leading-relaxed text-[var(--site-muted)]">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
<p class="prose-bookmark__meta mt-1 flex items-center gap-1.5 text-xs font-medium text-[var(--site-soft)]">
|
||||||
|
<svg class="shrink-0 opacity-80" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6" />
|
||||||
|
<path d="M11 13l9 -9" />
|
||||||
|
<path d="M15 4h5v5" />
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ url }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<p v-else class="prose-bookmark prose-bookmark-invalid my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5 text-sm font-semibold text-[var(--site-muted)]">
|
||||||
|
지원하지 않는 북마크 URL입니다.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
23
components/content/ProseButton.vue
Normal file
23
components/content/ProseButton.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
href: {
|
||||||
|
type: String,
|
||||||
|
default: '#'
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
type: String,
|
||||||
|
default: 'left'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p class="prose-button my-8" :class="{ 'text-center': align === 'center' }">
|
||||||
|
<NuxtLink
|
||||||
|
class="prose-button__link inline-flex items-center justify-center rounded-full bg-[var(--site-text)] px-5 py-2.5 text-sm font-semibold text-[var(--site-bg)] transition-opacity hover:opacity-80"
|
||||||
|
:to="href"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
88
components/content/ProseCallout.vue
Normal file
88
components/content/ProseCallout.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
emojiEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
type: String,
|
||||||
|
default: '💡'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
type: String,
|
||||||
|
default: 'blue'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const backgroundClass = computed(() => {
|
||||||
|
if (props.background === 'gray') {
|
||||||
|
return 'prose-callout--gray'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'green') {
|
||||||
|
return 'prose-callout--green'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'yellow') {
|
||||||
|
return 'prose-callout--yellow'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'red') {
|
||||||
|
return 'prose-callout--red'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'purple') {
|
||||||
|
return 'prose-callout--purple'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'pink') {
|
||||||
|
return 'prose-callout--pink'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'prose-callout--blue'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="prose-callout prose-callout-card mt-8 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
|
||||||
|
:class="backgroundClass"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span v-if="emojiEnabled" class="inline-flex shrink-0 text-[20px] leading-none">{{ emoji || '💡' }}</span>
|
||||||
|
<div class="min-w-0 flex-1 whitespace-pre-line">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose-callout--gray {
|
||||||
|
background: rgba(100, 116, 139, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--blue {
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--green {
|
||||||
|
background: rgba(34, 197, 94, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--yellow {
|
||||||
|
background: rgba(245, 158, 11, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--red {
|
||||||
|
background: rgba(239, 68, 68, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--purple {
|
||||||
|
background: rgba(168, 85, 247, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--pink {
|
||||||
|
background: rgba(236, 72, 153, 0.14);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
components/content/ProseEmbed.vue
Normal file
129
components/content/ProseEmbed.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { theme } = useThemeMode()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 영상 ID를 추출한다.
|
||||||
|
* @param {string} value - 임베드 URL
|
||||||
|
* @returns {string} YouTube 영상 ID
|
||||||
|
*/
|
||||||
|
const getYouTubeId = (value) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(value)
|
||||||
|
|
||||||
|
if (parsedUrl.hostname.includes('youtu.be')) {
|
||||||
|
return parsedUrl.pathname.replace('/', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedUrl.hostname.includes('youtube.com')) {
|
||||||
|
return parsedUrl.searchParams.get('v') || parsedUrl.pathname.split('/').pop() || ''
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twitter/X 게시물 ID를 추출한다.
|
||||||
|
* @param {string} value - 트윗 URL
|
||||||
|
* @returns {string} 상태 ID
|
||||||
|
*/
|
||||||
|
const getTweetId = (value) => {
|
||||||
|
try {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
const parsedUrl = new URL(trimmed)
|
||||||
|
const host = parsedUrl.hostname.replace(/^www\./, '')
|
||||||
|
|
||||||
|
if (!['twitter.com', 'x.com', 'mobile.twitter.com'].includes(host)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = parsedUrl.pathname.split('/').filter(Boolean)
|
||||||
|
const statusIdx = parts.indexOf('status')
|
||||||
|
|
||||||
|
if (statusIdx >= 0 && parts[statusIdx + 1]) {
|
||||||
|
return parts[statusIdx + 1].split(/[?#]/)[0] || ''
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const youtubeId = computed(() => getYouTubeId(props.url))
|
||||||
|
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
|
||||||
|
const tweetId = computed(() => getTweetId(props.url))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 링크로 열어도 되는 URL인지 확인한다.
|
||||||
|
* @param {string} value - 검사할 URL
|
||||||
|
* @returns {boolean} 허용 여부
|
||||||
|
*/
|
||||||
|
const isSafeExternalUrl = (value) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(value)
|
||||||
|
return ['http:', 'https:'].includes(parsedUrl.protocol)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeExternalUrl = computed(() => isSafeExternalUrl(props.url) ? props.url : '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twitter 공식 embed iframe 주소
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const tweetEmbedUrl = computed(() => {
|
||||||
|
if (!tweetId.value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const twitterTheme = theme.value === 'dark' ? 'dark' : 'light'
|
||||||
|
|
||||||
|
return `https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(tweetId.value)}&theme=${twitterTheme}&dnt=true`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||||
|
<iframe
|
||||||
|
v-if="youtubeEmbedUrl"
|
||||||
|
class="prose-embed__frame aspect-video w-full"
|
||||||
|
:src="youtubeEmbedUrl"
|
||||||
|
title="Embedded video"
|
||||||
|
loading="lazy"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowfullscreen
|
||||||
|
/>
|
||||||
|
<iframe
|
||||||
|
v-else-if="tweetEmbedUrl"
|
||||||
|
:key="tweetEmbedUrl"
|
||||||
|
class="prose-embed__tweet min-h-[420px] w-full border-0 sm:min-h-[458px]"
|
||||||
|
:src="tweetEmbedUrl"
|
||||||
|
title="Embedded post"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else-if="safeExternalUrl"
|
||||||
|
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"
|
||||||
|
:href="safeExternalUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{{ url }}
|
||||||
|
</a>
|
||||||
|
<p v-else class="prose-embed__invalid p-5 text-sm font-semibold text-[var(--site-muted)]">
|
||||||
|
지원하지 않는 임베드 URL입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
5
components/content/ProseFile.vue
Normal file
5
components/content/ProseFile.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose-file my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
components/content/ProseHeaderCard.vue
Normal file
17
components/content/ProseHeaderCard.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'simple'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="prose-header-card my-8 overflow-hidden rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] p-8 text-[var(--site-text)]"
|
||||||
|
:class="`prose-header-card--${variant}`"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
27
components/content/ProseHeading.vue
Normal file
27
components/content/ProseHeading.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="tagName"
|
||||||
|
class="prose-heading mt-12 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||||
|
:class="{
|
||||||
|
'text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]': level === 1,
|
||||||
|
'text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]': level === 2,
|
||||||
|
'text-[clamp(1.1rem,1.05rem+0.25vw,1.25rem)]': level === 3,
|
||||||
|
'text-[clamp(1.025rem,1rem+0.2vw,1.15rem)]': level === 4,
|
||||||
|
'text-[clamp(0.95rem,0.925rem+0.15vw,1.05rem)]': level === 5,
|
||||||
|
'text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]': level === 6
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
33
components/content/ProseImage.vue
Normal file
33
components/content/ProseImage.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'regular'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<figure
|
||||||
|
class="prose-image my-8"
|
||||||
|
:class="{
|
||||||
|
'prose-image--wide lg:-mx-10 lg:max-w-none': variant === 'wide',
|
||||||
|
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||||
|
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
|
||||||
|
</div>
|
||||||
|
<figcaption v-if="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-[var(--site-muted)]">
|
||||||
|
<slot />
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</template>
|
||||||
18
components/content/ProseList.vue
Normal file
18
components/content/ProseList.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
ordered: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="ordered ? 'ol' : 'ul'"
|
||||||
|
class="prose-list my-6 space-y-2 pl-5 text-[15px] leading-8 text-[var(--site-text)] marker:text-[var(--site-muted)]"
|
||||||
|
:class="ordered ? 'list-decimal' : 'list-disc'"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
5
components/content/ProseProduct.vue
Normal file
5
components/content/ProseProduct.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose-product my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
44
components/content/ProseSignup.vue
Normal file
44
components/content/ProseSignup.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '뉴스레터에 가입하세요'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '새 글이 올라오면 받아보실 수 있어요.'
|
||||||
|
},
|
||||||
|
buttonLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '구독하기'
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: 'you@example.com'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="prose-signup prose-signup-card my-8 rounded-[12px] border border-[var(--site-line)] bg-[var(--site-panel-strong)] px-5 py-8 text-center sm:px-8">
|
||||||
|
<h3 class="prose-signup__title text-[clamp(1rem,0.95rem+0.25vw,1.125rem)] font-semibold leading-snug text-[var(--site-text)]">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<p class="prose-signup__desc mt-2 text-[15px] leading-7 text-[var(--site-muted)]">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
<form class="prose-signup__form mt-6 flex flex-col items-stretch gap-2.5 sm:flex-row sm:justify-center" action="#" @submit.prevent>
|
||||||
|
<input
|
||||||
|
class="site-input prose-signup__input min-h-[44px] w-full rounded-full px-4 text-sm sm:max-w-[300px] sm:flex-1"
|
||||||
|
type="email"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
readonly
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="이메일"
|
||||||
|
>
|
||||||
|
<button class="site-accent-button prose-signup__submit min-h-[44px] shrink-0 rounded-full px-8 text-sm font-semibold" type="button">
|
||||||
|
{{ buttonLabel }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
19
components/content/ProseToggle.vue
Normal file
19
components/content/ProseToggle.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<details class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||||
|
<summary class="prose-toggle__summary cursor-pointer text-[15px] font-semibold leading-7 text-[var(--site-text)]">
|
||||||
|
{{ title }}
|
||||||
|
</summary>
|
||||||
|
<div class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
5
components/content/ProseVideo.vue
Normal file
5
components/content/ProseVideo.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose-video my-8 aspect-video overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
220
components/site/LeftSidebar.vue
Normal file
220
components/site/LeftSidebar.vue
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
menuOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isDarkMode, toggleTheme } = useThemeMode()
|
||||||
|
|
||||||
|
const { data: tags } = await useFetch('/api/tags', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: navigation } = await useFetch('/api/navigation', {
|
||||||
|
default: () => ({
|
||||||
|
primary: [],
|
||||||
|
footer: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sori-primary-nav-expanded'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리에서 하위가 있는 노드 id를 모은다.
|
||||||
|
* @param {Array<Object>} list - 노드 목록
|
||||||
|
* @returns {string[]} id 목록
|
||||||
|
*/
|
||||||
|
const collectBranchIds = (list) => {
|
||||||
|
const out = []
|
||||||
|
for (const node of list || []) {
|
||||||
|
if (node.children?.length) {
|
||||||
|
out.push(String(node.id))
|
||||||
|
out.push(...collectBranchIds(node.children))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage에서 펼침 상태를 읽는다.
|
||||||
|
* @returns {string[]|null} id 배열
|
||||||
|
*/
|
||||||
|
const readStoredExpanded = () => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? parsed.map(String) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 펼침 상태를 localStorage에 저장한다.
|
||||||
|
* @param {Set<string>} set - id 집합
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const persistExpanded = (set) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryNavExpandedSet = ref(new Set())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리 구조에 맞게 펼침 집합을 맞춘다.
|
||||||
|
* @param {Array<Object>} nodes - primary 트리
|
||||||
|
* @param {boolean} useStorage - 최초·복원 시 저장값 반영
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const syncPrimaryNavExpanded = (nodes, useStorage = false) => {
|
||||||
|
const allBranch = new Set(collectBranchIds(nodes))
|
||||||
|
if (useStorage) {
|
||||||
|
const stored = readStoredExpanded()
|
||||||
|
if (stored && stored.length) {
|
||||||
|
const next = new Set()
|
||||||
|
for (const id of stored) {
|
||||||
|
if (allBranch.has(id)) {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
primaryNavExpandedSet.value = next.size ? next : allBranch
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = new Set()
|
||||||
|
for (const id of primaryNavExpandedSet.value) {
|
||||||
|
if (allBranch.has(id)) {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
primaryNavExpandedSet.value = next.size ? next : allBranch
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 네비 폴더 펼침 토글
|
||||||
|
* @param {string} id - 노드 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const togglePrimaryNavBranch = (id) => {
|
||||||
|
const key = String(id)
|
||||||
|
const next = new Set(primaryNavExpandedSet.value)
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key)
|
||||||
|
} else {
|
||||||
|
next.add(key)
|
||||||
|
}
|
||||||
|
primaryNavExpandedSet.value = next
|
||||||
|
persistExpanded(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
provide('sidebarPrimaryNavExpandedSet', primaryNavExpandedSet)
|
||||||
|
provide('sidebarPrimaryNavToggle', togglePrimaryNavBranch)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => navigation.value?.primary,
|
||||||
|
(nodes) => {
|
||||||
|
syncPrimaryNavExpanded(nodes || [], false)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncPrimaryNavExpanded(navigation.value?.primary || [], true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
id="menu"
|
||||||
|
class="left-sidebar site-sidebar flex flex-col overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,transform,border-color] duration-300 ease-out max-lg:fixed max-lg:left-0 max-lg:top-[57px] max-lg:z-[60] max-lg:h-[calc(100dvh-57px)] max-lg:max-h-[calc(100dvh-57px)] max-lg:w-[min(287px,calc(100vw-24px))] max-lg:shadow-[0_16px_48px_rgba(0,0,0,0.18)] lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:self-start"
|
||||||
|
:class="menuOpen
|
||||||
|
? 'max-lg:translate-x-0 max-lg:pointer-events-auto lg:w-[287px] lg:opacity-100'
|
||||||
|
: 'max-lg:-translate-x-full max-lg:pointer-events-none lg:w-0 lg:opacity-0 lg:border-transparent'"
|
||||||
|
>
|
||||||
|
<div class="left-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||||
|
<div class="left-sidebar__block site-sidebar-section py-3 pl-4 pr-3 sm:pl-5 xl:pl-0">
|
||||||
|
<nav class="left-sidebar__nav" data-nav="menu">
|
||||||
|
<SidebarPrimaryNavList :nodes="navigation.primary" />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="left-sidebar__block site-sidebar-section px-5 py-4 pr-3 xl:pl-0">
|
||||||
|
<div class="left-sidebar__section-title flex items-center justify-between pr-2 text-xs font-semibold uppercase tracking-[0.01em] site-muted">
|
||||||
|
<span>Categories</span>
|
||||||
|
<span class="text-sm">⌃</span>
|
||||||
|
</div>
|
||||||
|
<div class="left-sidebar__category-grid mt-1.5 grid grid-cols-2 gap-x-2 gap-y-[2px] text-[0.8rem] font-medium">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="left-sidebar__category site-panel-hover group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
|
||||||
|
:to="`/tag/${tag.slug}`"
|
||||||
|
>
|
||||||
|
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||||
|
<span class="left-sidebar__category-name flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ tag.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="tag.postCount"
|
||||||
|
class="left-sidebar__category-count invisible text-xs font-medium opacity-0 transition-opacity duration-200 group-hover:visible group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
{{ tag.postCount }}
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
|
||||||
|
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
|
||||||
|
<span>Authors</span>
|
||||||
|
<span>⌃</span>
|
||||||
|
</div>
|
||||||
|
<div class="left-sidebar__authors mt-4 grid gap-4 text-sm">
|
||||||
|
<div class="left-sidebar__author flex items-center gap-3">
|
||||||
|
<span class="h-8 w-8 rounded-full bg-[#e7c49d]" />
|
||||||
|
<span><strong class="block">sori</strong><span class="site-soft">Editor</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="left-sidebar__author flex items-center gap-3">
|
||||||
|
<span class="h-8 w-8 rounded-full bg-[#98b7d5]" />
|
||||||
|
<span><strong class="block">zenn</strong><span class="site-soft">Writer</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">
|
||||||
|
<nav
|
||||||
|
class="left-sidebar__footer-nav flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1"
|
||||||
|
aria-label="하단 링크"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
v-for="item in navigation.footer"
|
||||||
|
:key="item.id"
|
||||||
|
class="left-sidebar__footer-link site-interactive shrink-0"
|
||||||
|
:to="item.url"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
<button
|
||||||
|
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
|
||||||
|
type="button"
|
||||||
|
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||||
|
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
|
||||||
|
@click="toggleTheme"
|
||||||
|
>
|
||||||
|
<span v-if="isDarkMode">☀</span>
|
||||||
|
<span v-else>☾</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
5
components/site/MainColumn.vue
Normal file
5
components/site/MainColumn.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="main-column w-full max-w-full lg:max-w-[720px]">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
36
components/site/PostCard.vue
Normal file
36
components/site/PostCard.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
post: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="post-card site-section site-panel-hover">
|
||||||
|
<div class="post-card__body site-section-body flex gap-4">
|
||||||
|
<img
|
||||||
|
v-if="post.featuredImage"
|
||||||
|
class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-surface object-cover"
|
||||||
|
:src="post.featuredImage"
|
||||||
|
:alt="post.title"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
<div v-else class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-[linear-gradient(135deg,#06333a,#f4a261)]" />
|
||||||
|
<div class="post-card__content min-w-0">
|
||||||
|
<h2 class="post-card__title text-base font-semibold leading-tight">
|
||||||
|
<NuxtLink class="post-card__title-link site-interactive hover:opacity-70" :to="post.to">
|
||||||
|
{{ post.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</h2>
|
||||||
|
<p class="post-card__excerpt mt-2 text-sm leading-6 site-muted">
|
||||||
|
{{ post.excerpt }}
|
||||||
|
</p>
|
||||||
|
<p class="post-card__meta mt-2 text-xs site-muted">
|
||||||
|
{{ post.publishedAt }} / {{ post.tag }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
193
components/site/RightSidebar.vue
Normal file
193
components/site/RightSidebar.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup>
|
||||||
|
const followLinks = [
|
||||||
|
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
|
||||||
|
{ id: 'x', label: 'X', href: 'https://x.com', icon: 'x' },
|
||||||
|
{ id: 'github', label: 'Github', href: 'https://github.com', icon: 'github' },
|
||||||
|
{ id: 'instagram', label: 'Instagram', href: 'https://instagram.com', icon: 'instagram' },
|
||||||
|
{ id: 'linkedin', label: 'Linkedin', href: 'https://linkedin.com', icon: 'linkedin' },
|
||||||
|
{ id: 'rss', label: 'RSS', href: '/rss/', icon: 'rss' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||||
|
default: () => ({
|
||||||
|
title: 'sori.studio',
|
||||||
|
description: 'sori.studio 개인 블로그',
|
||||||
|
logoText: '井',
|
||||||
|
logoUrl: '',
|
||||||
|
copyrightText: '©2026 sori.studio'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="right-sidebar site-sidebar flex w-full flex-col overflow-hidden border-[var(--site-line)] max-lg:border-l-0 max-lg:border-t max-lg:px-4 max-lg:pb-10 lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:w-[287px] lg:self-start lg:border-l lg:border-t-0 lg:px-0">
|
||||||
|
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||||
|
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0">
|
||||||
|
<div class="right-sidebar__profile flex items-center gap-3">
|
||||||
|
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
|
||||||
|
<img
|
||||||
|
v-if="siteSettings.logoUrl"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:src="siteSettings.logoUrl"
|
||||||
|
:alt="siteSettings.title"
|
||||||
|
>
|
||||||
|
<span v-else>{{ siteSettings.logoText }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="right-sidebar__title font-semibold">
|
||||||
|
{{ siteSettings.title }}
|
||||||
|
</p>
|
||||||
|
<p class="right-sidebar__description text-sm site-muted">
|
||||||
|
{{ siteSettings.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="right-sidebar__subscribe mt-4 flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||||
|
<input class="right-sidebar__input min-w-0 w-full flex-1 rounded-lg px-3 py-2 text-sm site-input sm:min-w-0" placeholder="Your email">
|
||||||
|
<button class="right-sidebar__button shrink-0 rounded-lg px-4 py-2 text-sm font-semibold site-button sm:self-auto" type="button">
|
||||||
|
Subscribe
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||||
|
<div class="right-sidebar__row flex items-center justify-between">
|
||||||
|
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||||
|
Follow
|
||||||
|
</p>
|
||||||
|
<nav class="right-sidebar__social relative z-10 flex flex-wrap items-center gap-1 text-sm text-[var(--site-text)]">
|
||||||
|
<a
|
||||||
|
v-for="item in followLinks"
|
||||||
|
:key="item.id"
|
||||||
|
class="site-interactive p-0.5 hover:opacity-75"
|
||||||
|
:href="item.href"
|
||||||
|
:aria-label="item.label"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="item.icon === 'facebook'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="h-4 w-4"
|
||||||
|
>
|
||||||
|
<path d="M7 10v4h3v7h4v-7h3l1-4h-4V8a1 1 0 0 1 1-1h3V3h-3a5 5 0 0 0-5 5v2z" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="item.icon === 'x'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="h-4 w-4"
|
||||||
|
>
|
||||||
|
<path d="M4 4l11.733 16H20L8.267 4z" />
|
||||||
|
<path d="M4 20l6.768-6.768m2.46-2.46L20 4" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="item.icon === 'github'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="h-4 w-4"
|
||||||
|
>
|
||||||
|
<path d="M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0c-2.4-1.6-3.5-1.3-3.5-1.3a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="item.icon === 'instagram'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="h-4 w-4"
|
||||||
|
>
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="4" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<line x1="16.5" y1="7.5" x2="16.5" y2="7.501" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="item.icon === 'linkedin'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="h-4 w-4"
|
||||||
|
>
|
||||||
|
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
|
||||||
|
<rect x="2" y="9" width="4" height="12" />
|
||||||
|
<circle cx="4" cy="4" r="2" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="h-4 w-4"
|
||||||
|
>
|
||||||
|
<circle cx="5" cy="19" r="1" />
|
||||||
|
<path d="M4 4a16 16 0 0 1 16 16" />
|
||||||
|
<path d="M4 11a9 9 0 0 1 9 9" />
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">{{ item.label }}</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||||
|
<div class="right-sidebar__row flex items-center justify-between">
|
||||||
|
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||||
|
Recommended
|
||||||
|
</p>
|
||||||
|
<span>↗</span>
|
||||||
|
</div>
|
||||||
|
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
|
||||||
|
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/post/hello-sori-studio">
|
||||||
|
sori.studio 첫 글과 방향
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/projects">
|
||||||
|
Projects and services
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/links">
|
||||||
|
Links and portal
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||||
|
<p class="right-sidebar__about text-sm leading-6 site-muted">
|
||||||
|
{{ siteSettings.description }}
|
||||||
|
</p>
|
||||||
|
<NuxtLink class="right-sidebar__about-button mt-4 inline-flex rounded-lg px-4 py-2 text-sm font-semibold site-accent-button" to="/pages/about">
|
||||||
|
About {{ siteSettings.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-3 text-xs site-muted max-lg:px-0">
|
||||||
|
{{ siteSettings.copyrightText }}
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
153
components/site/SidebarPrimaryNavList.vue
Normal file
153
components/site/SidebarPrimaryNavList.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
/** 공개 API `primary` 트리 노드 */
|
||||||
|
nodes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const expandedSet = inject('sidebarPrimaryNavExpandedSet')
|
||||||
|
const toggleBranch = inject('sidebarPrimaryNavToggle')
|
||||||
|
|
||||||
|
/** 세로바·호버 시 원형으로 바뀌는 공통 before 스타일(리프 링크와 동일) */
|
||||||
|
const navBarBeforeBase =
|
||||||
|
"before:pointer-events-none before:content-[''] before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full"
|
||||||
|
|
||||||
|
/** 비활성 경로: 기본 회색 막대, 호버 시 원형·믹스 색 */
|
||||||
|
const navBarBeforeInactive =
|
||||||
|
`${navBarBeforeBase} before:bg-[var(--site-line)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
|
||||||
|
|
||||||
|
/** 현재 페이지와 일치하는 내부 링크: 브랜드(`--site-accent`) 막대·호버 원형 */
|
||||||
|
const navBarBeforeActive =
|
||||||
|
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
|
||||||
|
|
||||||
|
/** 행 공통: site-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
|
||||||
|
const navRowShell =
|
||||||
|
'site-panel-hover flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 노드가 펼쳐져 있는지
|
||||||
|
* @param {string} id - 노드 id
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
const isExpanded = (id) => expandedSet?.value?.has(String(id)) ?? true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부모 행(이름·행 전체) 클릭으로 하위 접기/펼치기
|
||||||
|
* @param {string} id - 노드 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBranchClick = (id) => {
|
||||||
|
toggleBranch(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 URL 여부
|
||||||
|
* @param {string} raw - 네비 URL
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
const isExternalUrl = (raw) => /^https?:\/\//i.test(String(raw || '').trim())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내부 링크이고 현재 경로와 일치하는지(쿼리 무시, 끝 슬래시 정규화)
|
||||||
|
* @param {string} raw - 네비 URL
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
const isInternalNavActive = (raw) => {
|
||||||
|
const u = String(raw || '').trim()
|
||||||
|
if (!u || u === '#' || !u.startsWith('/') || u.startsWith('//')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (isExternalUrl(u)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const path = (route.path || '/').split('?')[0] || '/'
|
||||||
|
/**
|
||||||
|
* 경로 정규화
|
||||||
|
* @param {string} s - 경로
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const norm = (s) => {
|
||||||
|
let x = s || '/'
|
||||||
|
if (x.length > 1 && x.endsWith('/')) {
|
||||||
|
x = x.slice(0, -1)
|
||||||
|
}
|
||||||
|
return x || '/'
|
||||||
|
}
|
||||||
|
return norm(path) === norm(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리프 `NuxtLink`용 클래스
|
||||||
|
* @param {string} url - 노드 URL
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const navLinkClass = (url) => {
|
||||||
|
const active = isInternalNavActive(url)
|
||||||
|
const bar = active ? navBarBeforeActive : navBarBeforeInactive
|
||||||
|
return `sidebar-primary-nav-list__nav-link ${navRowShell} ${bar}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="sidebar-primary-nav-list flex w-full flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
|
||||||
|
<template v-for="node in nodes" :key="node.id">
|
||||||
|
<li
|
||||||
|
v-if="node.children?.length"
|
||||||
|
class="sidebar-primary-nav-list__branch w-full"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sidebar-primary-nav-list__branch-toggle group flex w-full max-w-full text-left text-[var(--site-text)]"
|
||||||
|
:class="`${navRowShell} ${navBarBeforeInactive}`"
|
||||||
|
:aria-expanded="isExpanded(node.id)"
|
||||||
|
:aria-label="isExpanded(node.id) ? `${node.label} 하위 메뉴 접기` : `${node.label} 하위 메뉴 펼치기`"
|
||||||
|
@click="onBranchClick(node.id)"
|
||||||
|
>
|
||||||
|
<span class="sidebar-primary-nav-list__branch-label min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
|
||||||
|
<span
|
||||||
|
class="sidebar-primary-nav-list__chevron-wrap grid h-5 w-5 shrink-0 place-items-center text-[var(--site-muted)] transition-transform duration-200 ease-out group-hover:text-[var(--site-text)]"
|
||||||
|
:class="{ '-rotate-180': isExpanded(node.id) }"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg class="sidebar-primary-nav-list__chevron-svg h-3.5 w-3.5" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.5 4.25L6 7.75L9.5 4.25" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="sidebar-primary-nav-list__sub-grid grid min-h-0 w-full max-w-full transition-[grid-template-rows] duration-200 ease-out"
|
||||||
|
:class="isExpanded(node.id) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
||||||
|
>
|
||||||
|
<div class="sidebar-primary-nav-list__sub-clip min-h-0 overflow-hidden">
|
||||||
|
<ul class="sidebar-primary-nav-list__sub ml-0 border-l border-[var(--site-line)] pl-2 pt-0">
|
||||||
|
<SidebarPrimaryNavList :nodes="node.children" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-else
|
||||||
|
class="sidebar-primary-nav-list__leaf group relative flex w-full max-w-full items-center"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="node.url && String(node.url).trim() !== '' && String(node.url).trim() !== '#'"
|
||||||
|
:class="navLinkClass(node.url)"
|
||||||
|
:to="node.url"
|
||||||
|
:aria-current="isInternalNavActive(node.url) ? 'page' : undefined"
|
||||||
|
>
|
||||||
|
<span class="sidebar-primary-nav-list__label min-w-0 flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="sidebar-primary-nav-list__leaf-static group text-[var(--site-text)]"
|
||||||
|
:class="`${navRowShell} ${navBarBeforeInactive}`"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
309
components/site/SiteHeader.vue
Normal file
309
components/site/SiteHeader.vue
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<script setup>
|
||||||
|
const { menuOpen, toggleMenu, closeMenu } = useMenuState()
|
||||||
|
const menuUserOpen = ref(false)
|
||||||
|
const userMenuRef = ref(null)
|
||||||
|
const userMenuToggleRef = ref(null)
|
||||||
|
const searchOpen = ref(false)
|
||||||
|
const member = ref(null)
|
||||||
|
|
||||||
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||||
|
default: () => ({
|
||||||
|
title: 'sori.studio',
|
||||||
|
logoUrl: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 메뉴를 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeUserMenu = () => {
|
||||||
|
menuUserOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통합 검색 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openSearchModal = () => {
|
||||||
|
searchOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력 필드에 포커스가 있으면 `/` 검색 단축키를 무시한다.
|
||||||
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
|
* @returns {boolean} 무시할 때 true
|
||||||
|
*/
|
||||||
|
const shouldIgnoreSearchHotkey = (event) => {
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const tag = target.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 메뉴를 토글한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleUserMenu = () => {
|
||||||
|
menuUserOpen.value = !menuUserOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 세션 정보를 조회한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const fetchMember = async () => {
|
||||||
|
try {
|
||||||
|
member.value = await $fetch('/api/auth/me')
|
||||||
|
} catch {
|
||||||
|
member.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 로그아웃을 처리한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const logoutMember = async () => {
|
||||||
|
await $fetch('/api/auth/logout', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
member.value = null
|
||||||
|
closeUserMenu()
|
||||||
|
await navigateTo('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 클릭 시 사용자 메뉴 외부 영역이면 메뉴를 닫는다.
|
||||||
|
* @param {MouseEvent} event - 클릭 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onDocumentClick = (event) => {
|
||||||
|
const target = /** @type {Node | null} */ (event.target instanceof Node ? event.target : null)
|
||||||
|
if (!target) {
|
||||||
|
closeUserMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInsideMenu = userMenuRef.value instanceof HTMLElement && userMenuRef.value.contains(target)
|
||||||
|
const isToggleButton = userMenuToggleRef.value instanceof HTMLElement && userMenuToggleRef.value.contains(target)
|
||||||
|
|
||||||
|
if (!isInsideMenu && !isToggleButton) {
|
||||||
|
closeUserMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape·`/` 키로 패널을 제어한다(검색 모달 → 사용자 메뉴 → 모바일 좌측 메뉴, `/`는 검색 열기).
|
||||||
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onGlobalKeydown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (searchOpen.value) {
|
||||||
|
searchOpen.value = false
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (menuUserOpen.value) {
|
||||||
|
closeUserMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!menuOpen.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) {
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== '/') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (shouldIgnoreSearchHotkey(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
openSearchModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchMember()
|
||||||
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
document.addEventListener('keydown', onGlobalKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onDocumentClick)
|
||||||
|
document.removeEventListener('keydown', onGlobalKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="site-header sticky top-0 z-20 backdrop-blur">
|
||||||
|
<div class="site-header__inner mx-auto grid h-full max-w-[1294px] grid-cols-3 items-center gap-2 px-4 sm:gap-3 lg:gap-4 lg:px-5 xl:gap-5 xl:px-6 2xl:px-0">
|
||||||
|
<div class="site-header__brand-slot flex min-w-0 justify-self-start">
|
||||||
|
<NuxtLink class="site-header__brand flex min-w-0 max-w-full items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,28vw)] xl:max-w-[min(300px,26vw)]" to="/">
|
||||||
|
<button
|
||||||
|
class="site-header__menu-toggle group flex h-7 w-7 items-center justify-center rounded-full transition-transform"
|
||||||
|
type="button"
|
||||||
|
data-menu-toggle
|
||||||
|
aria-label="Menu toggle"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-controls="menu"
|
||||||
|
:aria-expanded="menuOpen.toString()"
|
||||||
|
@click.prevent="toggleMenu"
|
||||||
|
>
|
||||||
|
<span v-if="menuOpen" class="site-header__menu-icon pointer-events-none">
|
||||||
|
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span v-else class="site-header__menu-icon pointer-events-none">
|
||||||
|
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
|
||||||
|
<path d="M9 4v16" />
|
||||||
|
</svg>
|
||||||
|
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
|
||||||
|
<path d="M9 4v16" />
|
||||||
|
<path d="m14 10 2 2-2 2" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="siteSettings.logoUrl"
|
||||||
|
class="site-header__brand-logo h-7 w-7 shrink-0 rounded-md object-cover"
|
||||||
|
:src="siteSettings.logoUrl"
|
||||||
|
:alt="siteSettings.title"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="site-header__search-slot flex min-w-0 justify-center justify-self-center px-1 sm:px-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="site-header__search site-header__search--responsive hidden h-9 w-full min-w-[470px] max-w-[min(470px,100%)] cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex site-input"
|
||||||
|
aria-label="검색 열기"
|
||||||
|
@click="openSearchModal"
|
||||||
|
>
|
||||||
|
<span class="site-header__search-icon mr-2 text-lg leading-none">⌕</span>
|
||||||
|
<span class="site-header__search-text site-soft">Search</span>
|
||||||
|
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav class="site-header__nav site-header__actions flex min-w-0 shrink-0 items-center justify-end justify-self-end gap-3 text-sm sm:gap-3.5">
|
||||||
|
<div class="site-header__user-menu relative">
|
||||||
|
<button
|
||||||
|
ref="userMenuToggleRef"
|
||||||
|
class="site-header__user-toggle relative flex h-7 w-7 items-center justify-center rounded-full border transition-opacity duration-200 hover:opacity-75 md:h-8 md:w-8"
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle user menu"
|
||||||
|
:aria-expanded="menuUserOpen.toString()"
|
||||||
|
@click="toggleUserMenu"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="member?.avatarUrl"
|
||||||
|
:src="member.avatarUrl"
|
||||||
|
:alt="member.username || '회원 아바타'"
|
||||||
|
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">
|
||||||
|
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-[transform,opacity,visibility] duration-200 ease-out"
|
||||||
|
enter-from-class="-translate-y-2 scale-95 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 scale-100 opacity-100"
|
||||||
|
leave-active-class="transition-[transform,opacity,visibility] duration-150 ease-in"
|
||||||
|
leave-from-class="translate-y-0 scale-100 opacity-100"
|
||||||
|
leave-to-class="-translate-y-2 scale-95 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="menuUserOpen"
|
||||||
|
ref="userMenuRef"
|
||||||
|
class="site-header__user-dropdown absolute top-12 right-2 z-30 flex min-w-[200px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3 pb-2 text-sm font-medium shadow-[0_12px_30px_rgba(0,0,0,0.12)] sm:right-0 sm:max-w-xs"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center gap-2 border-b border-[var(--site-line)] pb-3">
|
||||||
|
<div class="site-header__avatar-wrap flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-[var(--site-panel)] md:h-10 md:w-10">
|
||||||
|
<img
|
||||||
|
v-if="member?.avatarUrl"
|
||||||
|
:src="member.avatarUrl"
|
||||||
|
:alt="member.username || '회원 아바타'"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
>
|
||||||
|
<span v-else class="text-base font-normal uppercase md:text-lg">
|
||||||
|
{{ (member?.username || member?.email || '@').slice(0, 1) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<div class="max-w-xs truncate leading-[1.15]">
|
||||||
|
{{ member?.username || 'Anonymous' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
|
||||||
|
{{ member.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="member">
|
||||||
|
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/settings" @click="closeUserMenu">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||||
|
<path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
|
||||||
|
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 1-2 0 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 1 0-2 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 1 2 0 1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.24.36.48.69.6 1 .18.45.18 1.55 0 2-.12.31-.36.64-.6 1Z" />
|
||||||
|
</svg>
|
||||||
|
<span>설정</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<button class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 text-left transition-colors duration-150 hover:bg-[var(--site-panel)]" type="button" @click="logoutMember">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||||
|
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||||
|
<path d="M21 12h-13l3 -3" />
|
||||||
|
<path d="M11 15l-3 -3" />
|
||||||
|
</svg>
|
||||||
|
<span>로그아웃</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||||
|
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
||||||
|
<path d="M15 9l-6 6" />
|
||||||
|
<path d="M15 15v-6h-6" />
|
||||||
|
</svg>
|
||||||
|
<span>Sign up</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||||
|
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||||
|
<path d="M21 12h-13l3 -3" />
|
||||||
|
<path d="M11 15l-3 -3" />
|
||||||
|
</svg>
|
||||||
|
<span>Sign in</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<SiteSearchModal v-model="searchOpen" />
|
||||||
|
</template>
|
||||||
284
components/site/SiteSearchModal.vue
Normal file
284
components/site/SiteSearchModal.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* @typedef {{ name: string, slug: string }} SearchTagHit
|
||||||
|
* @typedef {{ slug: string, title: string, excerpt: string }} SearchPostHit
|
||||||
|
*/
|
||||||
|
|
||||||
|
const open = defineModel({ type: Boolean, default: false })
|
||||||
|
|
||||||
|
const searchInputRef = ref(null)
|
||||||
|
const query = ref('')
|
||||||
|
const debouncedQuery = ref('')
|
||||||
|
const isComposing = ref(false)
|
||||||
|
|
||||||
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||||
|
let debounceTimer = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력값을 디바운스로 검색어에 반영한다.
|
||||||
|
* @param {string} value - 현재 입력값
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const scheduleDebouncedQuery = (value) => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debouncedQuery.value = value
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, (value) => {
|
||||||
|
scheduleDebouncedQuery(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchKey = computed(() => `public-search:${debouncedQuery.value}`)
|
||||||
|
|
||||||
|
const { data, pending } = await useFetch('/api/search', {
|
||||||
|
key: fetchKey,
|
||||||
|
query: { q: debouncedQuery },
|
||||||
|
watch: [debouncedQuery],
|
||||||
|
default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) })
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정규식 특수 문자 이스케이프
|
||||||
|
* @param {string} value - 원문
|
||||||
|
* @returns {string} 이스케이프된 문자열
|
||||||
|
*/
|
||||||
|
const escapeRegExp = (value) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색어 일치 구간을 나누어 하이라이트 표시에 사용한다.
|
||||||
|
* @param {string} text - 표시할 문자열
|
||||||
|
* @param {string} needle - 검색어
|
||||||
|
* @returns {Array<{ text: string, hit: boolean }>} 구간 목록
|
||||||
|
*/
|
||||||
|
const highlightParts = (text, needle) => {
|
||||||
|
const base = String(text ?? '')
|
||||||
|
const q = String(needle ?? '').trim()
|
||||||
|
if (!q) {
|
||||||
|
return [{ text: base, hit: false }]
|
||||||
|
}
|
||||||
|
const parts = base.split(new RegExp(`(${escapeRegExp(q)})`, 'gi'))
|
||||||
|
return parts
|
||||||
|
.filter((part) => part !== '')
|
||||||
|
.map((part, index) => ({
|
||||||
|
text: part,
|
||||||
|
hit: index % 2 === 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목록용 요약 문자열을 한 줄로 자른다.
|
||||||
|
* @param {string} text - 요약 원문
|
||||||
|
* @param {number} max - 최대 글자 수
|
||||||
|
* @returns {string} 잘린 문자열
|
||||||
|
*/
|
||||||
|
const clipExcerpt = (text, max = 140) => {
|
||||||
|
const line = String(text ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (line.length <= max) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return `${line.slice(0, max)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배경 클릭 시 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBackdropPointerDown = () => {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색어를 비운다(입력 포커스 유지).
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const clearQuery = () => {
|
||||||
|
query.value = ''
|
||||||
|
debouncedQuery.value = ''
|
||||||
|
nextTick(() => {
|
||||||
|
const el = searchInputRef.value
|
||||||
|
if (el instanceof HTMLInputElement) {
|
||||||
|
el.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한글/일본어 등 IME 조합 시작 처리
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onCompositionStart = () => {
|
||||||
|
isComposing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IME 조합 중에도 입력창 value를 기반으로 검색어를 갱신한다.
|
||||||
|
* 일부 환경에서는 v-model 업데이트가 조합 종료까지 지연될 수 있다.
|
||||||
|
* @param {CompositionEvent} event - 조합 업데이트 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onCompositionUpdate = (event) => {
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof HTMLInputElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const value = target.value
|
||||||
|
query.value = value
|
||||||
|
scheduleDebouncedQuery(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신)
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onCompositionEnd = () => {
|
||||||
|
isComposing.value = false
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debouncedQuery.value = query.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 열림·닫힘에 따라 스크롤 잠금과 입력을 동기화한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
document.documentElement.classList.toggle('site-search-open', Boolean(isOpen))
|
||||||
|
if (isOpen) {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = searchInputRef.value
|
||||||
|
if (el instanceof HTMLInputElement) {
|
||||||
|
el.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query.value = ''
|
||||||
|
debouncedQuery.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.classList.remove('site-search-open')
|
||||||
|
}
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-show="open"
|
||||||
|
class="site-search-modal fixed inset-0 z-[60] flex justify-center bg-black/35 px-3 pt-14 pb-8 backdrop-blur-sm sm:px-4 sm:pt-20"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="사이트 검색"
|
||||||
|
@pointerdown.self="onBackdropPointerDown"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="site-search-modal__panel site-search-modal__panel--animate flex h-[min(78vh,640px)] w-full max-w-[95vw] flex-col overflow-hidden rounded-lg border border-[var(--site-line)] bg-[var(--site-bg)] text-[var(--site-text)] shadow-[0_20px_50px_rgba(0,0,0,0.18)] sm:max-w-lg"
|
||||||
|
@pointerdown.stop
|
||||||
|
>
|
||||||
|
<div class="site-search-modal__header flex shrink-0 items-center gap-2 border-b border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-3 sm:gap-3 sm:px-5 sm:py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="site-search-modal__icon flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-[var(--site-text)] transition-colors hover:bg-[var(--site-panel)]"
|
||||||
|
:aria-label="query.trim() ? '검색어 지우기' : '검색'"
|
||||||
|
:disabled="!query.trim()"
|
||||||
|
@click="query.trim() ? clearQuery() : null"
|
||||||
|
>
|
||||||
|
<svg v-if="!query.trim()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M23.38 21.62l-6.53-6.53a9.15 9.15 0 0 0 1.9-5.59 9.27 9.27 0 1 0-3.66 7.36l6.53 6.53a1.26 1.26 0 0 0 1.76 0 1.25 1.25 0 0 0 0-1.77ZM2.75 9.5A6.75 6.75 0 1 1 9.5 16.25 6.76 6.76 0 0 1 2.75 9.5Z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="searchInputRef"
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
inputmode="search"
|
||||||
|
enterkeyhint="search"
|
||||||
|
autocomplete="off"
|
||||||
|
class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]"
|
||||||
|
placeholder="글 제목, 본문, 태그 검색"
|
||||||
|
@compositionstart="onCompositionStart"
|
||||||
|
@compositionupdate="onCompositionUpdate"
|
||||||
|
@compositionend="onCompositionEnd"
|
||||||
|
/>
|
||||||
|
<button type="button" class="site-search-modal__cancel shrink-0 rounded-md px-2 py-1 text-sm text-[var(--site-soft)] hover:text-[var(--site-text)] sm:hidden" @click="open = false">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="site-search-modal__body min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 sm:px-5 sm:py-4">
|
||||||
|
<p v-if="!query.trim()" class="site-search-modal__hint text-sm text-[var(--site-soft)]">
|
||||||
|
검색어를 입력하면 태그와 게시물이 섹션별로 표시됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="pending" class="text-sm text-[var(--site-soft)]">
|
||||||
|
검색 중…
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<section v-if="(data?.tags?.length ?? 0) > 0" class="site-search-modal__section mb-6">
|
||||||
|
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||||
|
Tags
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
<li v-for="tag in data.tags" :key="tag.slug">
|
||||||
|
<NuxtLink
|
||||||
|
class="site-search-modal__tag-link flex items-baseline gap-1 rounded-md px-2 py-2 text-[15px] transition-colors hover:bg-[var(--site-panel)]"
|
||||||
|
:to="`/tag/${tag.slug}/`"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
<span class="text-[var(--site-soft)]">#</span>
|
||||||
|
<span class="font-medium text-[var(--site-text)]">{{ tag.name }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__section">
|
||||||
|
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||||
|
Posts
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li v-for="post in data.posts" :key="post.slug">
|
||||||
|
<NuxtLink
|
||||||
|
class="site-search-modal__post-link block rounded-md px-2 py-2 transition-colors hover:bg-[var(--site-panel)]"
|
||||||
|
:to="`/post/${post.slug}/`"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
<div class="text-[15px] font-semibold leading-snug text-[var(--site-text)]">
|
||||||
|
<template v-for="(seg, i) in highlightParts(post.title, query)" :key="`t-${post.slug}-${i}`">
|
||||||
|
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-text)]">{{ seg.text }}</mark>
|
||||||
|
<span v-else>{{ seg.text }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="clipExcerpt(post.excerpt)" class="mt-0.5 line-clamp-2 text-sm leading-relaxed text-[var(--site-soft)]">
|
||||||
|
<template v-for="(seg, i) in highlightParts(clipExcerpt(post.excerpt), query)" :key="`e-${post.slug}-${i}`">
|
||||||
|
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-muted)]">{{ seg.text }}</mark>
|
||||||
|
<span v-else>{{ seg.text }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
|
||||||
|
일치하는 결과가 없습니다.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
25
components/site/TagHeader.vue
Normal file
25
components/site/TagHeader.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="tag-header site-section">
|
||||||
|
<div class="tag-header__inner site-section-header">
|
||||||
|
<h1 class="tag-header__title mt-3 text-xl font-semibold leading-tight">
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
<p v-if="description" class="tag-header__description mt-3 text-sm leading-6 text-muted">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
22
composables/formatPostDate.js
Normal file
22
composables/formatPostDate.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 공개 화면용 게시 날짜를 YYYY.MM.DD 형식으로 변환한다.
|
||||||
|
* @param {string | null | undefined} value - ISO 8601 등 파싱 가능한 날짜 문자열
|
||||||
|
* @returns {string} 빈 문자열 또는 YYYY.MM.DD
|
||||||
|
*/
|
||||||
|
export function formatPostDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
51
composables/useAdminToast.js
Normal file
51
composables/useAdminToast.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const TOAST_AUTO_HIDE_MS = 4000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 화면 우측 상단 피드백 토스트. 모달(z-50 등)보다 위에 보이도록 사용처에서 `z-[100]` 클래스를 둔다.
|
||||||
|
* @returns {{ toast: import('vue').Ref<null | { type: string, message: string }>, showToast: (type: string, message: string) => void, clearToast: () => void }}
|
||||||
|
*/
|
||||||
|
export const useAdminToast = () => {
|
||||||
|
const toast = ref(null)
|
||||||
|
let toastTimer = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시 중인 토스트를 즉시 제거한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const clearToast = () => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
toastTimer = null
|
||||||
|
toast.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토스트를 표시한다. 이전 타이머가 있으면 취소한다.
|
||||||
|
* @param {'success' | 'error' | 'info'} type - 토스트 종류
|
||||||
|
* @param {string} message - 본문
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showToast = (type, message) => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
const text = String(message || '').trim() || '알림'
|
||||||
|
toast.value = {
|
||||||
|
type,
|
||||||
|
message: text
|
||||||
|
}
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toast.value = null
|
||||||
|
toastTimer = null
|
||||||
|
}, TOAST_AUTO_HIDE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
toast,
|
||||||
|
showToast,
|
||||||
|
clearToast
|
||||||
|
}
|
||||||
|
}
|
||||||
100
composables/useAdminUnsavedChangesGuard.js
Normal file
100
composables/useAdminUnsavedChangesGuard.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 관리자 편집 화면의 미저장 변경 이탈을 막는다.
|
||||||
|
* @param {import('vue').Ref<boolean> | import('vue').ComputedRef<boolean>} isDirty - 변경 여부
|
||||||
|
* @param {{ onLeaveConfirmed?: () => void | Promise<void> }} options - 이탈 승인 옵션
|
||||||
|
* @returns {{
|
||||||
|
* isUnsavedModalOpen: import('vue').Ref<boolean>,
|
||||||
|
* stayOnUnsavedPage: () => void,
|
||||||
|
* leaveUnsavedPage: () => Promise<void>,
|
||||||
|
* allowNextRouteLeave: () => void
|
||||||
|
* }} 이탈 확인 상태와 동작
|
||||||
|
*/
|
||||||
|
export const useAdminUnsavedChangesGuard = (isDirty, options = {}) => {
|
||||||
|
const isUnsavedModalOpen = ref(false)
|
||||||
|
const pendingRoute = ref(null)
|
||||||
|
const isNextRouteAllowed = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 이탈을 막아야 하는지 확인한다.
|
||||||
|
* @returns {boolean} 이탈 차단 여부
|
||||||
|
*/
|
||||||
|
const shouldBlockLeave = () => Boolean(unref(isDirty)) && !isNextRouteAllowed.value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다음 라우트 이동을 한 번 허용한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const allowNextRouteLeave = () => {
|
||||||
|
isNextRouteAllowed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 페이지에 머문다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const stayOnUnsavedPage = () => {
|
||||||
|
pendingRoute.value = null
|
||||||
|
isUnsavedModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미저장 변경을 버리고 이동한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const leaveUnsavedPage = async () => {
|
||||||
|
const route = pendingRoute.value
|
||||||
|
pendingRoute.value = null
|
||||||
|
isUnsavedModalOpen.value = false
|
||||||
|
isNextRouteAllowed.value = true
|
||||||
|
|
||||||
|
await options.onLeaveConfirmed?.()
|
||||||
|
|
||||||
|
if (route?.fullPath) {
|
||||||
|
await navigateTo(route.fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeRouteLeave((to) => {
|
||||||
|
if (isNextRouteAllowed.value) {
|
||||||
|
isNextRouteAllowed.value = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldBlockLeave()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRoute.value = to
|
||||||
|
isUnsavedModalOpen.value = true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브라우저 탭 닫기와 새로고침을 기본 확인창으로 막는다.
|
||||||
|
* @param {BeforeUnloadEvent} event - 브라우저 이탈 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleBeforeUnload = (event) => {
|
||||||
|
if (!shouldBlockLeave()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUnsavedModalOpen,
|
||||||
|
stayOnUnsavedPage,
|
||||||
|
leaveUnsavedPage,
|
||||||
|
allowNextRouteLeave
|
||||||
|
}
|
||||||
|
}
|
||||||
44
composables/useMenuState.js
Normal file
44
composables/useMenuState.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const menuStorageKey = 'MENU_STATE'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌측 메뉴 열림 상태 관리
|
||||||
|
* @returns {{menuOpen: import('vue').Ref<boolean>, toggleMenu: Function, closeMenu: Function}} 메뉴 상태와 토글·닫기
|
||||||
|
*/
|
||||||
|
export const useMenuState = () => {
|
||||||
|
const menuOpen = useState('site-menu-open', () => true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedState = localStorage.getItem(menuStorageKey)
|
||||||
|
|
||||||
|
if (savedState) {
|
||||||
|
menuOpen.value = savedState === 'open'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌측 메뉴 열림 상태 토글
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleMenu = () => {
|
||||||
|
menuOpen.value = !menuOpen.value
|
||||||
|
localStorage.setItem(menuStorageKey, menuOpen.value ? 'open' : 'closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌측 메뉴를 닫는다(모바일 오버레이·백드롭에서 사용).
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeMenu = () => {
|
||||||
|
if (!menuOpen.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
menuOpen.value = false
|
||||||
|
localStorage.setItem(menuStorageKey, 'closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
menuOpen,
|
||||||
|
toggleMenu,
|
||||||
|
closeMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
66
composables/useThemeMode.js
Normal file
66
composables/useThemeMode.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const themeStorageKey = 'SITE_THEME'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 루트 요소에 현재 테마를 반영한다.
|
||||||
|
* @param {'light' | 'dark'} theme - 적용할 테마
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const applyThemeToDocument = (theme) => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme = theme
|
||||||
|
document.documentElement.style.colorScheme = theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 시스템 테마를 조회한다.
|
||||||
|
* @returns {'light' | 'dark'} 시스템 기준 기본 테마
|
||||||
|
*/
|
||||||
|
const getSystemTheme = () => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사이트 라이트/다크 테마 상태를 관리한다.
|
||||||
|
* @returns {{theme: import('vue').Ref<'light' | 'dark'>, isDarkMode: import('vue').ComputedRef<boolean>, toggleTheme: Function}} 테마 상태와 제어 함수
|
||||||
|
*/
|
||||||
|
export const useThemeMode = () => {
|
||||||
|
const theme = useState('site-theme-mode', () => 'light')
|
||||||
|
const isDarkMode = computed(() => theme.value === 'dark')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedTheme = localStorage.getItem(themeStorageKey)
|
||||||
|
const nextTheme = savedTheme === 'light' || savedTheme === 'dark' ? savedTheme : getSystemTheme()
|
||||||
|
theme.value = nextTheme
|
||||||
|
applyThemeToDocument(nextTheme)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(theme, (nextTheme) => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(themeStorageKey, nextTheme)
|
||||||
|
applyThemeToDocument(nextTheme)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이트/다크 테마를 전환한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleTheme = () => {
|
||||||
|
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
isDarkMode,
|
||||||
|
toggleTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
58
db/migrations/001_initial_schema.sql
Normal file
58
db/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
excerpt TEXT NOT NULL DEFAULT '',
|
||||||
|
featured_image TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft', 'private'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS posts_status_published_at_idx
|
||||||
|
ON posts (status, published_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
featured_image TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
color TEXT NOT NULL DEFAULT '#15171a',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE tags
|
||||||
|
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE tags
|
||||||
|
ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#15171a';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_sort_order_name_idx
|
||||||
|
ON tags (sort_order ASC, name ASC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_tags (
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (post_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS post_tags_tag_id_idx
|
||||||
|
ON post_tags (tag_id);
|
||||||
65
db/migrations/002_seed_development.sql
Normal file
65
db/migrations/002_seed_development.sql
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
INSERT INTO tags (id, name, slug, description, sort_order, color)
|
||||||
|
VALUES
|
||||||
|
('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.', 10, '#f97316'),
|
||||||
|
('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.', 20, '#06b6d4')
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO posts (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
content,
|
||||||
|
excerpt,
|
||||||
|
status,
|
||||||
|
published_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'11111111-1111-4111-8111-111111111111',
|
||||||
|
'sori.studio를 직접 만들기 시작하며',
|
||||||
|
'hello-sori-studio',
|
||||||
|
'개인 블로그와 포털 역할을 한 공간에 담기 위한 첫 글입니다.',
|
||||||
|
'블로그와 포털의 경계에 있는 개인 공간을 직접 구축하기 위한 첫 기록입니다.',
|
||||||
|
'published',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'22222222-2222-4222-8222-222222222222',
|
||||||
|
'글쓰기 도구는 왜 직접 만들게 되는가',
|
||||||
|
'custom-writing-tool',
|
||||||
|
'기존 도구를 거치며 남은 취향의 빈칸을 직접 채우는 과정입니다.',
|
||||||
|
'네이버 블로그, 티스토리, 워드프레스, Ghost를 거쳐 남은 취향의 빈칸을 정리합니다.',
|
||||||
|
'published',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z'
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO post_tags (post_id, tag_id)
|
||||||
|
VALUES
|
||||||
|
('11111111-1111-4111-8111-111111111111', '44444444-4444-4444-8444-444444444444'),
|
||||||
|
('22222222-2222-4222-8222-222222222222', '55555555-5555-4555-8555-555555555555')
|
||||||
|
ON CONFLICT (post_id, tag_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO pages (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
content,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'33333333-3333-4333-8333-333333333333',
|
||||||
|
'About',
|
||||||
|
'about',
|
||||||
|
'sori.studio 소개 페이지입니다.',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z'
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
18
db/migrations/003_add_tag_display_fields.sql
Normal file
18
db/migrations/003_add_tag_display_fields.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
ALTER TABLE tags
|
||||||
|
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE tags
|
||||||
|
ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#15171a';
|
||||||
|
|
||||||
|
UPDATE tags
|
||||||
|
SET sort_order = 10,
|
||||||
|
color = '#f97316'
|
||||||
|
WHERE slug = 'note';
|
||||||
|
|
||||||
|
UPDATE tags
|
||||||
|
SET sort_order = 20,
|
||||||
|
color = '#06b6d4'
|
||||||
|
WHERE slug = 'dev';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_sort_order_name_idx
|
||||||
|
ON tags (sort_order ASC, name ASC);
|
||||||
28
db/migrations/004_add_site_settings.sql
Normal file
28
db/migrations/004_add_site_settings.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
title TEXT NOT NULL DEFAULT 'sori.studio',
|
||||||
|
description TEXT NOT NULL DEFAULT 'sori.studio 개인 블로그',
|
||||||
|
site_url TEXT NOT NULL DEFAULT 'https://sori.studio',
|
||||||
|
logo_text TEXT NOT NULL DEFAULT '井',
|
||||||
|
copyright_text TEXT NOT NULL DEFAULT '©2026 sori.studio',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT site_settings_singleton_check CHECK (id = 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO site_settings (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
site_url,
|
||||||
|
logo_text,
|
||||||
|
copyright_text
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
1,
|
||||||
|
'sori.studio',
|
||||||
|
'sori.studio 개인 블로그',
|
||||||
|
'https://sori.studio',
|
||||||
|
'井',
|
||||||
|
'©2026 sori.studio'
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
38
db/migrations/005_add_navigation_items.sql
Normal file
38
db/migrations/005_add_navigation_items.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS navigation_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
location TEXT NOT NULL DEFAULT 'primary',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_visible BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (location, label, url),
|
||||||
|
CONSTRAINT navigation_items_location_check CHECK (location IN ('primary', 'footer'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS navigation_items_location_sort_order_idx
|
||||||
|
ON navigation_items (location, sort_order ASC, label ASC);
|
||||||
|
|
||||||
|
INSERT INTO navigation_items (label, url, location, sort_order, is_visible)
|
||||||
|
SELECT seed.label, seed.url, seed.location, seed.sort_order, seed.is_visible
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
('Home pages', '/', 'primary', 10, true),
|
||||||
|
('Tags', '/tags', 'primary', 20, true),
|
||||||
|
('Authors', '/pages/about', 'primary', 30, true),
|
||||||
|
('Style', '/post/hello-sori-studio', 'primary', 40, true),
|
||||||
|
('Post types', '/post/custom-writing-tool', 'primary', 50, true),
|
||||||
|
('Members', '/pages/contact', 'primary', 60, true),
|
||||||
|
('Landing pages', '/pages/projects', 'primary', 70, true),
|
||||||
|
('Portal', '/pages/links', 'footer', 10, true),
|
||||||
|
('Docs', '/pages/about', 'footer', 20, true),
|
||||||
|
('Projects', '/pages/projects', 'footer', 30, true)
|
||||||
|
) AS seed(label, url, location, sort_order, is_visible)
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM navigation_items existing
|
||||||
|
WHERE existing.location = seed.location
|
||||||
|
AND existing.label = seed.label
|
||||||
|
AND existing.url = seed.url
|
||||||
|
);
|
||||||
9
db/migrations/006_add_media_metadata.sql
Normal file
9
db/migrations/006_add_media_metadata.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS media_metadata (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
category TEXT NOT NULL DEFAULT '미분류',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS media_metadata_category_idx
|
||||||
|
ON media_metadata (category ASC);
|
||||||
9
db/migrations/007_add_media_folders.sql
Normal file
9
db/migrations/007_add_media_folders.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS media_folders (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO media_folders (path)
|
||||||
|
VALUES ('미분류')
|
||||||
|
ON CONFLICT (path) DO NOTHING;
|
||||||
11
db/migrations/008_add_post_seo_fields.sql
Normal file
11
db/migrations/008_add_post_seo_fields.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_title TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_description TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS canonical_url TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS noindex BOOLEAN NOT NULL DEFAULT false;
|
||||||
2
db/migrations/009_add_post_og_image.sql
Normal file
2
db/migrations/009_add_post_og_image.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS og_image TEXT;
|
||||||
30
db/migrations/010_add_members_and_comments.sql
Normal file
30
db/migrations/010_add_members_and_comments.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS users_email_idx
|
||||||
|
ON users (email);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID REFERENCES comments(id) ON DELETE CASCADE,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'published',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT comments_status_check CHECK (status IN ('published', 'pending', 'blocked'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS comments_post_id_created_at_idx
|
||||||
|
ON comments (post_id, created_at ASC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS comments_parent_id_idx
|
||||||
|
ON comments (parent_id);
|
||||||
|
|
||||||
25
db/migrations/011_add_member_profile_and_activity.sql
Normal file
25
db/migrations/011_add_member_profile_and_activity.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS avatar_url TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS last_seen_ip TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
WITH deduplicated_users AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY lower(username) ORDER BY created_at ASC, id ASC) AS row_number
|
||||||
|
FROM users
|
||||||
|
)
|
||||||
|
UPDATE users
|
||||||
|
SET username = deduplicated_users.username || '-' || deduplicated_users.row_number
|
||||||
|
FROM deduplicated_users
|
||||||
|
WHERE users.id = deduplicated_users.id
|
||||||
|
AND deduplicated_users.row_number > 1;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique_idx
|
||||||
|
ON users (lower(username));
|
||||||
|
|
||||||
9
db/migrations/012_add_comment_likes.sql
Normal file
9
db/migrations/012_add_comment_likes.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS comment_likes (
|
||||||
|
comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (comment_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS comment_likes_user_id_idx
|
||||||
|
ON comment_likes (user_id);
|
||||||
12
db/migrations/013_add_user_admin_role.sql
Normal file
12
db/migrations/013_add_user_admin_role.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
WITH first_user AS (
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
UPDATE users
|
||||||
|
SET is_admin = true
|
||||||
|
WHERE id IN (SELECT id FROM first_user);
|
||||||
27
db/migrations/014_add_user_role_levels.sql
Normal file
27
db/migrations/014_add_user_role_levels.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS user_role TEXT NOT NULL DEFAULT 'member';
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET user_role = CASE
|
||||||
|
WHEN is_admin THEN 'admin'
|
||||||
|
ELSE 'member'
|
||||||
|
END
|
||||||
|
WHERE user_role NOT IN ('owner', 'admin', 'member');
|
||||||
|
|
||||||
|
WITH first_user AS (
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
UPDATE users
|
||||||
|
SET user_role = 'owner'
|
||||||
|
WHERE id IN (SELECT id FROM first_user)
|
||||||
|
AND is_admin = true;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP CONSTRAINT IF EXISTS users_user_role_check;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_user_role_check
|
||||||
|
CHECK (user_role IN ('owner', 'admin', 'member'));
|
||||||
13
db/migrations/015_add_tag_type_and_reorder_support.sql
Normal file
13
db/migrations/015_add_tag_type_and_reorder_support.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE tags
|
||||||
|
ADD COLUMN IF NOT EXISTS tag_type TEXT NOT NULL DEFAULT 'managed';
|
||||||
|
|
||||||
|
UPDATE tags
|
||||||
|
SET tag_type = 'managed'
|
||||||
|
WHERE tag_type NOT IN ('managed', 'general');
|
||||||
|
|
||||||
|
ALTER TABLE tags
|
||||||
|
DROP CONSTRAINT IF EXISTS tags_tag_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE tags
|
||||||
|
ADD CONSTRAINT tags_tag_type_check
|
||||||
|
CHECK (tag_type IN ('managed', 'general'));
|
||||||
12
db/migrations/016_media_category_normalize.sql
Normal file
12
db/migrations/016_media_category_normalize.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- 게시물 업로드 경로 기본 분류(posts) 및 구 프로필 경로(회원/썸네일)를 논리 폴더 정책에 맞게 정리한다.
|
||||||
|
UPDATE media_metadata
|
||||||
|
SET
|
||||||
|
category = '미분류',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE category = 'posts';
|
||||||
|
|
||||||
|
UPDATE media_metadata
|
||||||
|
SET
|
||||||
|
category = '썸네일',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE category = '회원/썸네일';
|
||||||
9
db/migrations/017_navigation_hierarchy.sql
Normal file
9
db/migrations/017_navigation_hierarchy.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- 상단(primary) 네비게이션 계층·폴더(접기) 지원, 하단(footer)은 평면 유지
|
||||||
|
ALTER TABLE navigation_items
|
||||||
|
ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES navigation_items (id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_folder BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE navigation_items DROP CONSTRAINT IF EXISTS navigation_items_location_label_url_key;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS navigation_items_location_parent_sort_idx
|
||||||
|
ON navigation_items (location, parent_id, sort_order ASC, label ASC);
|
||||||
15
db/migrations/018_email_otp_challenges.sql
Normal file
15
db/migrations/018_email_otp_challenges.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS email_otp_challenges (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
verify_attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT email_otp_challenges_purpose_check CHECK (purpose IN ('signup', 'password_reset'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS email_otp_challenges_email_purpose_created_idx
|
||||||
|
ON email_otp_challenges (lower(email), purpose, created_at DESC);
|
||||||
23
db/migrations/019_dedupe_navigation_items.sql
Normal file
23
db/migrations/019_dedupe_navigation_items.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- 반복 마이그레이션 실행으로 생긴 동일 위치·상위·라벨·URL 메뉴 중복 정리
|
||||||
|
WITH ranked_navigation AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY location, COALESCE(parent_id::text, ''), label, url
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN is_folder THEN 0 ELSE 1 END,
|
||||||
|
sort_order ASC,
|
||||||
|
created_at ASC,
|
||||||
|
id ASC
|
||||||
|
) AS row_rank
|
||||||
|
FROM navigation_items
|
||||||
|
)
|
||||||
|
DELETE FROM navigation_items
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM ranked_navigation
|
||||||
|
WHERE row_rank > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS navigation_items_location_parent_label_url_unique_idx
|
||||||
|
ON navigation_items (location, COALESCE(parent_id::text, ''), label, url);
|
||||||
5
db/migrations/020_add_member_admin_fields.sql
Normal file
5
db/migrations/020_add_member_admin_fields.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS member_labels TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS member_note TEXT NOT NULL DEFAULT '';
|
||||||
12
db/migrations/021_add_member_previous_login.sql
Normal file
12
db/migrations/021_add_member_previous_login.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS previous_last_seen_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS previous_last_seen_ip TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
previous_last_seen_at = last_seen_at,
|
||||||
|
previous_last_seen_ip = last_seen_ip
|
||||||
|
WHERE previous_last_seen_at IS NULL
|
||||||
|
AND last_seen_at IS NOT NULL;
|
||||||
5
db/migrations/022_add_site_logo_urls.sql
Normal file
5
db/migrations/022_add_site_logo_urls.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_url TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS favicon_url TEXT NOT NULL DEFAULT '';
|
||||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
sori-studio:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: sori-studio
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env.production}
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-43118}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./public/uploads:/app/public/uploads
|
||||||
|
depends_on:
|
||||||
|
- sori-studio-db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
sori-studio-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: sori-studio-db
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env.production}
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-43119}:5432"
|
||||||
|
volumes:
|
||||||
|
- sori-studio-postgres:/var/lib/postgresql/data
|
||||||
|
- ./db/migrations:/docker-entrypoint-initdb.d:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sori-studio-postgres:
|
||||||
@@ -1,5 +1,48 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.0.0
|
||||||
|
|
||||||
|
- 운영 시작 기준 버전.
|
||||||
|
- 운영 환경 DB 설정 누락 시 샘플 콘텐츠 대신 즉시 실패하도록 보강.
|
||||||
|
- 회원 세션 비밀값을 관리자 비밀번호와 분리.
|
||||||
|
- JavaScript 문법 점검과 프로덕션 빌드를 묶은 검증 스크립트 추가.
|
||||||
|
- Nitro 보안 권고 반영 및 취약점 0건 확인.
|
||||||
|
- Docker compose 설정과 앱 이미지 빌드 검증 완료.
|
||||||
|
|
||||||
|
## v0.0.6
|
||||||
|
|
||||||
|
- `.env.example`을 실제 비밀값이 없는 공유 템플릿으로 정리.
|
||||||
|
- 로컬 개발 전용 `.env.development`를 생성하고 개발 DB/관리자 비밀번호를 랜덤 값으로 분리.
|
||||||
|
- 개발/운영 환경 변수 파일 관리 기준을 문서화.
|
||||||
|
- 패키지 버전을 0.0.6으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.5
|
||||||
|
|
||||||
|
- PostgreSQL 초기 스키마와 개발용 시드 데이터를 추가.
|
||||||
|
- Nuxt 서버 API에 DB 저장소 계층을 추가.
|
||||||
|
- DB 연결이 없을 때는 샘플 데이터로 동작하도록 fallback 구조를 추가.
|
||||||
|
- Docker Compose에 PostgreSQL 서비스를 추가.
|
||||||
|
|
||||||
|
## v0.0.4
|
||||||
|
|
||||||
|
- 헤더 좌측 아이콘을 사이드바 메뉴 토글 버튼으로 수정.
|
||||||
|
- 좌측 사이드바 열림 상태를 저장하고 복원하는 기능 추가.
|
||||||
|
- Nuxt/Vue 방식으로 원본 테마의 Alpine식 메뉴 토글 동작을 구현.
|
||||||
|
|
||||||
|
## v0.0.3
|
||||||
|
|
||||||
|
- 공개 화면의 라이트/다크 색상 토큰을 추가.
|
||||||
|
- 좌우 사이드바가 헤더 아래 전체 높이를 차지하도록 레이아웃 보정.
|
||||||
|
- Thred 참고 화면에 가깝게 헤더, 히어로, 사이드바 임시 콘텐츠를 보강.
|
||||||
|
|
||||||
|
## v0.0.2
|
||||||
|
|
||||||
|
- Nuxt 3 기반 프로젝트 실행 구조를 추가.
|
||||||
|
- Tailwind CSS, Zod, Nuxt 서버 API 초기 골격을 추가.
|
||||||
|
- 공개 화면, 관리자 화면, 콘텐츠 컴포넌트의 기본 파일 구조를 생성.
|
||||||
|
- Docker 기반 NAS 배포 초안을 추가.
|
||||||
|
- 프로젝트 전용 개발/운영 포트 기준을 추가.
|
||||||
|
|
||||||
## v0.0.1
|
## v0.0.1
|
||||||
|
|
||||||
- sori.studio 개인 블로그/CMS 초기 방향 정리.
|
- sori.studio 개인 블로그/CMS 초기 방향 정리.
|
||||||
|
|||||||
@@ -19,14 +19,19 @@
|
|||||||
- Vue 컴포넌트 파일: PascalCase
|
- Vue 컴포넌트 파일: PascalCase
|
||||||
- CSS 클래스: kebab-case
|
- CSS 클래스: kebab-case
|
||||||
- 고유 클래스명 필수 (Tailwind 외)
|
- 고유 클래스명 필수 (Tailwind 외)
|
||||||
|
- Nuxt 컴포넌트 자동 import는 경로 prefix 없이 파일명 기준으로 사용
|
||||||
|
|
||||||
## 스타일
|
## 스타일
|
||||||
|
|
||||||
- TailwindCSS 기본 사용
|
- TailwindCSS 기본 사용
|
||||||
- 주요 요소: Tailwind + 고유 className 동시 적용
|
- 다크 인증(`signin`/`signup`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
|
||||||
|
- Tailwind 엔트리는 `nuxt.config.js`의 `tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음).
|
||||||
|
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
|
||||||
|
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast`의 `showToast`로 우측 상단(`z-[100]`)에 표시한다.
|
||||||
|
- 관리자 메뉴 관리는 상단/하단 탭으로 나누고, 순서는 드래그로 조정한다(상단은 동일 부모의 형제끼리만).
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<main class="site-main w-[720px]">
|
<main class="site-main w-full max-w-full lg:max-w-[720px]">
|
||||||
```
|
```
|
||||||
|
|
||||||
## 주석
|
## 주석
|
||||||
@@ -50,3 +55,10 @@
|
|||||||
- 하드코딩 금지
|
- 하드코딩 금지
|
||||||
- 로컬 개발 설정과 NAS 운영 설정은 별도 환경 파일로 분리
|
- 로컬 개발 설정과 NAS 운영 설정은 별도 환경 파일로 분리
|
||||||
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
|
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
|
||||||
|
- 운영 환경에서는 `DATABASE_URL`과 `MEMBER_SESSION_SECRET` 누락을 허용하지 않음
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `npm run lint`: JavaScript 파일 문법 점검
|
||||||
|
- `npm run test`: Nuxt 프로덕션 빌드 기반 회귀 검증
|
||||||
|
- `npm run verify`: 문법 점검과 빌드를 함께 실행
|
||||||
|
|||||||
189
docs/deploy.md
189
docs/deploy.md
@@ -1,6 +1,6 @@
|
|||||||
# 배포 가이드
|
# 배포 가이드
|
||||||
|
|
||||||
> 현재 프로젝트는 코드 스캐폴딩 전 상태다. 아래 내용은 Nuxt 앱 생성 후 적용할 기본 배포 방향이다.
|
> 로컬 기준 `npm run build`, `docker compose --env-file .env.production config --quiet`, `docker compose --env-file .env.production build sori-studio` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||||
|
|
||||||
## 빌드 유형
|
## 빌드 유형
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
|
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
|
||||||
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
|
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
|
||||||
|
| 검증 | `npm run verify` | JavaScript 문법 점검 + 프로덕션 빌드 |
|
||||||
|
|
||||||
|
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -15,7 +18,7 @@
|
|||||||
|
|
||||||
### 필수 조건
|
### 필수 조건
|
||||||
|
|
||||||
- Node.js 20+ 권장
|
- Node.js 22 LTS 권장
|
||||||
- npm 9+
|
- npm 9+
|
||||||
- 개발 DB
|
- 개발 DB
|
||||||
|
|
||||||
@@ -32,23 +35,77 @@ cd sori.studio
|
|||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 개발 환경 변수 설정
|
# 개발 환경 변수 설정
|
||||||
|
# .env.development는 Git에 올리지 않는 로컬 전용 파일
|
||||||
|
# 새로 만들 때는 .env.example을 복사한 뒤 비밀번호를 랜덤 값으로 교체
|
||||||
cp .env.example .env.development
|
cp .env.example .env.development
|
||||||
# .env.development 파일에 개발 DB 연결 정보 입력
|
openssl rand -hex 32
|
||||||
|
# 로컬 DB 컨테이너를 호스트에서 접근할 때는 127.0.0.1:43119 사용
|
||||||
|
|
||||||
# 개발 서버 실행
|
# 개발 서버 실행 (127.0.0.1:43117)
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 로컬 개발 DB
|
||||||
|
|
||||||
|
로컬 개발 DB는 Docker Compose의 `sori-studio-db` 서비스만 실행한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker daemon 시작
|
||||||
|
# Docker Desktop을 사용하면 Docker.app을 먼저 실행
|
||||||
|
# Colima를 사용하면 아래 명령 실행
|
||||||
|
colima start
|
||||||
|
|
||||||
|
# 개발 DB 컨테이너 실행
|
||||||
|
ENV_FILE=.env.development docker compose --env-file .env.development up -d sori-studio-db
|
||||||
|
|
||||||
|
# 개발 DB 마이그레이션 실행
|
||||||
|
npm run db:migrate:dev
|
||||||
|
|
||||||
|
# DB 준비 상태 확인
|
||||||
|
docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
|
||||||
|
|
||||||
|
# 시드 데이터 확인
|
||||||
|
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
|
||||||
|
```
|
||||||
|
|
||||||
### 확인 주소
|
### 확인 주소
|
||||||
|
|
||||||
- 개발 서버: http://localhost:3000
|
- 개발 서버: http://127.0.0.1:43117
|
||||||
- 관리자: http://localhost:3000/admin
|
- 관리자: http://127.0.0.1:43117/admin
|
||||||
|
- Tailwind Viewer: http://127.0.0.1:43117/_tailwind/
|
||||||
|
|
||||||
|
### 로컬 DB 확인 방법
|
||||||
|
|
||||||
|
로컬 개발 DB는 PostgreSQL이며 호스트에서는 `127.0.0.1:43119`로 접근한다. 접속 정보는 Git에 포함하지 않는 `.env.development` 값을 사용한다.
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|----|
|
||||||
|
| Host | `127.0.0.1` |
|
||||||
|
| Port | `43119` |
|
||||||
|
| Database | `.env.development`의 `POSTGRES_DB` |
|
||||||
|
| User | `.env.development`의 `POSTGRES_USER` |
|
||||||
|
| Password | `.env.development`의 `POSTGRES_PASSWORD` |
|
||||||
|
|
||||||
|
터미널에서 바로 확인할 때는 컨테이너 내부 `psql`을 사용한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DB 준비 상태 확인
|
||||||
|
docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
|
||||||
|
|
||||||
|
# 게시물 개수 확인
|
||||||
|
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
|
||||||
|
|
||||||
|
# psql 콘솔 접속
|
||||||
|
docker exec -it sori-studio-db psql -U sori_studio -d sori_studio
|
||||||
|
```
|
||||||
|
|
||||||
|
GUI로 확인할 때는 DBeaver, TablePlus, DataGrip, CloudBeaver 같은 PostgreSQL 클라이언트에서 위 접속 정보를 입력한다. phpMyAdmin은 MySQL/MariaDB용 도구라 이 프로젝트의 PostgreSQL DB 확인 용도로는 사용하지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## UGREEN NAS Docker 배포
|
## UGREEN NAS Docker 배포
|
||||||
|
|
||||||
> Dockerfile과 docker-compose 설정은 아직 작성 전이다.
|
> Dockerfile과 docker-compose 설정은 초안이며 NAS 운영 환경에서는 아직 검증 전이다.
|
||||||
|
|
||||||
### SSH 접속
|
### SSH 접속
|
||||||
|
|
||||||
@@ -69,11 +126,14 @@ git clone https://git.sori.studio/zenn/sori.studio.git
|
|||||||
cd sori.studio
|
cd sori.studio
|
||||||
|
|
||||||
# 운영 환경 변수 설정
|
# 운영 환경 변수 설정
|
||||||
|
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
||||||
cp .env.example .env.production
|
cp .env.example .env.production
|
||||||
# .env.production 파일에 운영 DB 연결 정보 입력
|
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
||||||
|
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
||||||
|
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
||||||
|
|
||||||
# Docker 빌드 및 실행
|
# Docker 빌드 및 실행
|
||||||
docker-compose up -d
|
docker compose --env-file .env.production up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 프로덕션 빌드 (NAS에서)
|
### 프로덕션 빌드 (NAS에서)
|
||||||
@@ -89,7 +149,10 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
|
|
||||||
### 포트
|
### 포트
|
||||||
|
|
||||||
- HTTP: 3000
|
- 로컬 개발: 43117
|
||||||
|
- NAS Docker 외부: 43118
|
||||||
|
- 컨테이너 내부: 3000
|
||||||
|
- PostgreSQL 외부: 43119
|
||||||
- HTTPS: 3001 (SSL 설정 시)
|
- HTTPS: 3001 (SSL 설정 시)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -98,13 +161,119 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
|
|
||||||
- 로컬 개발: `.env.development`의 `DATABASE_URL`
|
- 로컬 개발: `.env.development`의 `DATABASE_URL`
|
||||||
- NAS 운영: `.env.production`의 `DATABASE_URL`
|
- NAS 운영: `.env.production`의 `DATABASE_URL`
|
||||||
|
- 로컬 개발 예시: `postgres://sori_studio:비밀번호@127.0.0.1:43119/sori_studio`
|
||||||
|
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
||||||
|
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
||||||
|
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||||
|
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
||||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||||
|
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
|
||||||
|
|
||||||
|
### 이메일 인증(Resend, 선택)
|
||||||
|
|
||||||
|
회원가입(일반)·비밀번호 찾기에 이메일 OTP를 쓰려면 `npm run db:migrate:dev`로 `018_email_otp_challenges.sql`을 적용하고, `.env`에 다음을 설정한다.
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `RESEND_API_KEY` | [Resend](https://resend.com) API 키 |
|
||||||
|
| `RESEND_FROM_EMAIL` | 발신 주소(Resend에서 허용된 도메인 또는 테스트 발신자) |
|
||||||
|
| `MEMBER_SESSION_SECRET` | 회원 세션 쿠키 서명용 비밀값. 운영에서는 필수이며 `ADMIN_PASSWORD`와 분리된 긴 난수 문자열을 사용한다. **OTP 해시에 쓰는 pepper로도 사용**되므로, `EMAIL_OTP_PEPPER`를 비워 두면 이 값이 OTP용 비밀 재료가 된다. |
|
||||||
|
| `EMAIL_OTP_PEPPER` | **선택.** 이메일로 받은 6자리 숫자를 DB에 넣기 전 SHA256 해시할 때 섞는 **서버 전용 비밀 문자열**이다. DB가 유출돼도 pepper를 모르면 인증번호 역산·무차별 대입이 어렵다. **짧은 숫자 한두 개가 아니라**, `openssl rand -hex 32`처럼 **긴 난수 문자열(32바이트 이상 권장)**을 쓰는 것이 안전하다. 비우면 `MEMBER_SESSION_SECRET`을 pepper로 쓴다. |
|
||||||
|
|
||||||
|
`RESEND_API_KEY`와 `RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다.
|
||||||
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
||||||
|
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
||||||
|
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||||
|
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
||||||
|
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
|
||||||
|
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
|
||||||
|
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
|
||||||
|
|
||||||
|
### 개발/운영 DB 분리 검증 절차
|
||||||
|
|
||||||
|
검증 전제는 실제 비밀번호나 전체 `DATABASE_URL`을 화면 공유, 문서, 커밋 메시지에 노출하지 않는 것이다. 확인할 때는 호스트, 포트, DB 이름, 파일명만 대조한다.
|
||||||
|
|
||||||
|
1. `.env.development` 확인.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로컬 개발 DB는 호스트 기준 127.0.0.1:43119를 사용해야 한다.
|
||||||
|
# DATABASE_URL 전체 값은 공유하지 않는다.
|
||||||
|
rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|DB_PORT)=" .env.development
|
||||||
|
```
|
||||||
|
|
||||||
|
기준:
|
||||||
|
|
||||||
|
- `DATABASE_URL` 호스트가 `127.0.0.1`
|
||||||
|
- `DATABASE_URL` 포트가 `43119`
|
||||||
|
- `DB_PORT=43119`
|
||||||
|
- 운영 NAS 호스트명, 운영 IP, 운영 DB 이름이 포함되지 않음
|
||||||
|
|
||||||
|
2. `.env.production` 확인.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 운영 파일은 Git에 올리지 않는 운영 전용 파일이다.
|
||||||
|
# 값이 없으면 NAS 배포 전 작성해야 한다.
|
||||||
|
test -f .env.production && rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|APP_PORT)=" .env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
기준:
|
||||||
|
|
||||||
|
- NAS Docker 내부 실행 기준이면 `DATABASE_URL` 호스트가 `sori-studio-db`
|
||||||
|
- NAS 외부 DB를 별도 인스턴스로 쓰는 경우에도 로컬 개발 DB(`127.0.0.1:43119`)를 가리키지 않음
|
||||||
|
- `APP_PORT=43118`
|
||||||
|
- `MEMBER_SESSION_SECRET`이 비어 있지 않고 `ADMIN_PASSWORD`와 다름
|
||||||
|
- `.env.development`와 DB 비밀번호, 관리자 비밀번호가 서로 다름
|
||||||
|
|
||||||
|
3. 로컬 개발 DB 연결 확인.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
|
||||||
|
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT current_database(), current_user;'
|
||||||
|
```
|
||||||
|
|
||||||
|
기준:
|
||||||
|
|
||||||
|
- `accepting connections` 표시
|
||||||
|
- `current_database`가 로컬 개발 DB 이름
|
||||||
|
- `current_user`가 로컬 개발 DB 계정
|
||||||
|
|
||||||
|
4. 로컬 개발 서버 연결 확인.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
기준:
|
||||||
|
|
||||||
|
- 출력 주소가 `http://127.0.0.1:43117`
|
||||||
|
- 관리자 API 요청에서 `127.0.0.1:43119` 연결 오류가 발생하지 않음
|
||||||
|
|
||||||
|
5. 커밋 전 민감 정보 확인.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
git diff -- . ':!package-lock.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
기준:
|
||||||
|
|
||||||
|
- `.env.development`, `.env.production`이 변경 목록에 포함되지 않음
|
||||||
|
- 문서와 코드 diff에 실제 DB 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음
|
||||||
|
|
||||||
|
## 업로드 파일
|
||||||
|
|
||||||
|
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||||
|
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||||
|
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||||
|
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
||||||
|
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||||
|
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||||
|
|
||||||
## 사용자 액션 필요 항목
|
## 사용자 액션 필요 항목
|
||||||
|
|
||||||
- NAS SSH 접속 주소 확인.
|
- NAS SSH 접속 주소 확인.
|
||||||
- NAS 프로젝트 루트 경로 확정.
|
- NAS 프로젝트 루트 경로 확정.
|
||||||
- 운영 DB 이름, 계정, 권한 확정.
|
- 운영 DB 이름, 계정, 권한 확정.
|
||||||
|
- 운영 업로드 볼륨 경로 확정.
|
||||||
- 도메인 `sori.studio`의 NAS 연결 방식 확정.
|
- 도메인 `sori.studio`의 NAS 연결 방식 확정.
|
||||||
|
|||||||
839
docs/history.md
839
docs/history.md
@@ -1,5 +1,844 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-14 v1.0.0
|
||||||
|
|
||||||
|
### 운영 환경의 샘플 콘텐츠 fallback 차단
|
||||||
|
|
||||||
|
개발 단계에서는 DB 없이도 화면 구조를 확인할 수 있도록 샘플 게시물 fallback이 유용했지만, 운영 환경에서 `DATABASE_URL` 누락을 샘플 콘텐츠로 숨기면 잘못된 배포를 정상 서비스처럼 보이게 만든다. 따라서 `NODE_ENV=production`에서는 DB URL이 없으면 즉시 실패하도록 바꾸고, 샘플 콘텐츠 fallback은 개발 환경의 보조 장치로만 남긴다.
|
||||||
|
|
||||||
|
### 회원 세션 비밀값 분리
|
||||||
|
|
||||||
|
회원 세션 서명값이 `ADMIN_PASSWORD`로 fallback되면 관리자 로그인 비밀번호와 회원 쿠키 서명 책임이 섞인다. 운영에서 키 회전과 사고 대응을 분리할 수 있도록 `MEMBER_SESSION_SECRET`을 필수값으로 두고, 누락 시 명확한 서버 오류를 반환한다.
|
||||||
|
|
||||||
|
### 최소 회귀 검증 스크립트 추가
|
||||||
|
|
||||||
|
현재 프로젝트에는 전용 테스트 프레임워크가 없으므로 먼저 적용 가능한 최소 자동 검증으로 JavaScript 문법 점검과 Nuxt 프로덕션 빌드를 묶었다. `npm run verify`는 이후 단위 테스트나 E2E 테스트가 추가될 때 같은 진입점으로 확장한다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.121
|
||||||
|
|
||||||
|
### 자동 저장 안내를 툴바로 이동
|
||||||
|
|
||||||
|
자동 저장본이 있을 때 본문 상단에 배너를 띄우면 제목·본문 입력 흐름을 가리고 시각적으로도 무겁다. 이미 툴바 상태 영역에 자동 저장 시각 안내 문자열을 표시하고 있으므로, 같은 줄에 복원과 로컬 초안 삭제(무시)만 작은 버튼으로 붙이면 기능은 유지하면서 편집 영역 침범을 없앨 수 있다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.120
|
||||||
|
|
||||||
|
### 발행 모달을 Ghost 설정 행 패턴에 맞춤
|
||||||
|
|
||||||
|
첫 구현은 설정 제목과 버튼이 항상 펼쳐져 있어 고스트의 `gh-publish-setting`처럼 “현재 값만 보이다가 클릭 시 옵션 노출” 흐름과 달랐다. 사용자가 제공한 마크업에 맞춰 종이비행기·시계·펼침 화살표 SVG를 그대로 쓰고, 행 단위 접기/펼침으로 요약 표시를 맞췄다. 설정 블록 외곽의 상하 보더는 제거하고 행 사이 구분선만 두어 시각적 잡음을 줄였다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.119
|
||||||
|
|
||||||
|
### 게시물 저장 전 최종 발행 모달 도입
|
||||||
|
|
||||||
|
우측 설정 패널의 상태 셀렉트만으로 발행/초안/비공개를 반복 전환하는 흐름은 저장 전 최종 상태를 한눈에 확인하기 어렵고 조작도 번거롭다. 저장 버튼을 눌렀을 때 고스트 스타일의 전체 화면 발행 모달을 열고, 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 버튼식으로 빠르게 선택한 뒤 최종 확정하도록 정리했다. 뉴스레터 관련 섹션은 현재 기능 범위에 없으므로 제외했다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.119
|
||||||
|
|
||||||
|
### 콜아웃을 옵션 메타 기반으로 확장
|
||||||
|
|
||||||
|
콜아웃은 단순 본문만 저장하면 디자인 옵션(이모지 노출 여부, 이모지 종류, 배경 톤)을 유지할 수 없어, 작성 화면과 공개 화면의 결과를 일치시키기 어렵다. 기존 fenced 문법을 유지하면서 선언부 메타(`emoji`, `bg`)를 추가해 저장 포맷 변경 범위를 최소화했다. 이 방식은 기존 `:::callout` 콘텐츠와 호환되며, 이후 색상/아이콘 프리셋이 늘어나도 본문 포맷을 다시 바꾸지 않고 확장할 수 있다.
|
||||||
|
편집 UI는 카드 내부에 옵션 컨트롤을 넣으면 실제 공개 결과와 작성 화면이 달라 보이므로, 고스트처럼 콜아웃 카드 자체는 결과 형태를 유지하고 설정은 별도 패널로 분리했다.
|
||||||
|
콜아웃 카드 자체는 테두리 장식이 과하면 본문 흐름에서 떠 보이므로 보더를 제거하고 배경 톤 중심으로 정리했다. 이모지 선택은 정해진 목록만 강제하지 않고 별도 팝업 입력을 함께 제공해 시스템 이모지 입력 흐름을 수용한다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.118
|
||||||
|
|
||||||
|
### 게시글 저장·삭제 액션 강조도 조정
|
||||||
|
|
||||||
|
게시글 편집 화면의 저장 버튼은 변경사항이 없을 때도 활성화되어 있어 실제 저장 필요 상태를 구분하기 어렵다. 이미 미저장 변경사항 감지 기준을 가지고 있으므로 같은 기준으로 저장 버튼 활성 상태를 제어한다. 삭제 버튼은 파괴적 액션이지만 항상 빨간색이면 편집 흐름에서 과하게 눈에 띄므로 기본 상태는 중립 톤으로 두고 hover 시에만 위험 색상으로 전환한다. 태그 삭제는 텍스트 `x` 대신 SVG 닫기 아이콘을 사용해 배지 안 정렬을 안정화한다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.117
|
||||||
|
|
||||||
|
### 갤러리 선택과 순서 편집 흐름 정리
|
||||||
|
|
||||||
|
갤러리는 단일 이미지 블록과 달리 여러 이미지를 한 번에 구성하는 블록이므로, 미디어 클릭 즉시 적용하면 선택을 이어갈 수 없고 실수 수정도 번거롭다. 갤러리 미디어 선택은 모달 안에서 복수 선택 상태를 유지한 뒤 확인 시점에 블록에 반영한다. 이미지 개수별 열 수를 1·2·3열로 제한해 빈 칸을 줄이고, 작성자가 시각 흐름을 직접 정할 수 있도록 갤러리 내부 이미지는 드래그로 재정렬한다. 드래그 중에는 이미지 사이 삽입 위치를 선으로 표시해 어느 위치에 들어갈지 명확히 보여준다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.116
|
||||||
|
|
||||||
|
### 게시글 제목 IME 입력과 목록 태그 표시 보정
|
||||||
|
|
||||||
|
게시글 제목 입력에서 한글 조합 중 Enter를 본문 포커스 이동으로 함께 처리하면 마지막 조합 글자가 본문 에디터에 들어갈 수 있다. Enter가 IME 조합 확정인지 일반 이동 명령인지 구분해 조합 중에는 본문 이동을 막는다. 게시글 목록 태그는 편집 가능한 입력과 달리 삭제 액션이 필요 없으므로, 태그 관리와 같은 배지 인식성만 유지한 읽기 전용 형태로 표시한다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.115
|
||||||
|
|
||||||
|
### 사이트 설정과 계정 설정 책임 분리
|
||||||
|
|
||||||
|
관리자 사이트 설정에 관리자 프로필과 비밀번호 변경을 함께 두면 사이트 메타데이터와 개인 계정 관리가 섞인다. 계정 정보는 멤버 편집 화면에서 처리하고, 사이트 설정은 이름·설명·URL·로고·저작권처럼 공개 사이트 자체에 영향을 주는 값만 남긴다. 공개 사용자 설정은 중앙 컬럼 폭이 좁아 3분할 구조가 답답하므로 요약을 상단에 둔 세로형 흐름으로 바꾸고, 로고는 텍스트 대신 1:1 이미지를 저장해 공개 로고와 파비콘에 함께 사용한다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.114
|
||||||
|
|
||||||
|
### 멤버 계정 작업과 사용자 설정 화면 정리
|
||||||
|
|
||||||
|
관리자 하단의 `내 프로필`은 공개 사용자 설정으로 이동하면 관리자 컨텍스트가 끊기므로, 같은 계정이라도 관리자 멤버 편집 화면으로 보내 계정 관리 흐름을 유지한다. 비밀번호 직접 변경은 이메일 전송 장애 같은 비상 상황을 위한 관리자 전용 보조 수단으로 두고, 일반 사용자 설정에서는 비밀번호 변경과 회원 탈퇴를 상시 노출하지 않고 설정 메뉴의 모달 액션으로 낮췄다. 마지막 로그인은 현재 세션 조회 때마다 갱신하면 의미가 흐려지므로, 로그인 성공 시 기존 `last_seen_*` 값을 `previous_last_seen_*`로 옮긴 뒤 현재 로그인만 갱신한다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.113
|
||||||
|
|
||||||
|
### 멤버 필터와 썸네일 편집 방식 정리
|
||||||
|
|
||||||
|
멤버 목록은 검색만으로는 “Gmail 사용자 제외”, “비활성 사용자”, “특정 날짜 이후 접속 없음”처럼 운영자가 자주 쓰는 조건을 표현하기 어렵다. 서버 API를 먼저 확장하지 않고 현재 화면의 회원 목록 데이터를 기준으로 클라이언트 조건 필터를 제공해 UI 흐름을 빠르게 검증한다. 멤버 상세의 썸네일은 URL 문자열보다 이미지 자체를 클릭해 등록·변경·제거하는 방식이 더 자연스러우므로, URL 입력 필드는 숨기고 요약 영역의 원형 썸네일 액션으로 책임을 옮긴다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.112
|
||||||
|
|
||||||
|
### 관리자 편집 화면 이탈 확인 공통화
|
||||||
|
|
||||||
|
게시글 작성 화면은 로컬 자동 저장이 있지만 서버 저장 전 변경사항을 사용자가 의식하지 못한 채 목록으로 이동할 수 있었다. 멤버 편집 화면도 같은 편집 맥락을 가지므로, 라우트 이탈은 Ghost형 공통 모달로 한 번 확인하고 브라우저 새로고침·탭 닫기는 브라우저 기본 확인에 맡긴다. 게시글에서 이탈을 승인한 경우에는 임시 자동 저장본도 함께 버려 사용자가 명시적으로 떠난 내용을 다음 진입 때 다시 제안하지 않도록 한다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.111
|
||||||
|
|
||||||
|
### 관리자 멤버 상세와 추가 화면 분리
|
||||||
|
|
||||||
|
멤버 목록에서 모든 편집 기능을 처리하면 목록의 스캔성이 떨어지고 권한 변경 같은 민감 액션도 너무 쉽게 노출된다. 목록은 관측과 진입에 집중하고, 개별 회원의 이름·이메일·레이블·관리자 노트는 별도 상세 화면에서 저장한다. 레이블은 아직 공개 기능에 쓰지 않지만 이후 사용자별 칭호나 분류로 확장할 수 있도록 배열 컬럼으로 두고, 신규 회원은 활동 이력이 없으므로 활동 섹션을 렌더링하지 않는다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.110
|
||||||
|
|
||||||
|
### 관리자 멤버 목록 정보 밀도 정리
|
||||||
|
|
||||||
|
멤버 목록에서 이름, 이메일, 접속일, 권한 변경 컨트롤을 모두 별도 컬럼으로 두면 한 사람의 정보가 가로로 흩어지고 목록이 지나치게 넓어진다. Ghost 관리자처럼 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 묶어 읽는 구조로 바꾸고, 권한 변경은 사용자를 선택한 뒤 처리하는 후속 화면의 책임으로 분리한다. 뉴스레터 지표는 이 프로젝트에 없으므로 같은 위치에는 댓글 작성 개수를 표시한다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.109
|
||||||
|
|
||||||
|
### 관리자 사이드바 하단 사용자 영역 정리
|
||||||
|
|
||||||
|
상단 메뉴 아래에 로그아웃이 바로 붙어 있으면 Ghost형 관리자 내비게이션의 정보 구조와 달라지고, 주요 메뉴와 세션 액션이 같은 레벨로 보인다. 로그아웃은 하단 사용자 썸네일 드롭다운으로 옮기고, 설정은 하단 아이콘으로 배치해 상단 메뉴는 콘텐츠 관리 항목 중심으로 유지한다. 멤버 항목에는 총 멤버 수를 함께 보여 관리자가 현재 규모를 즉시 확인할 수 있게 한다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.108
|
||||||
|
|
||||||
|
### 관리자 캔버스 높이와 사이드바 폭 정리
|
||||||
|
|
||||||
|
관리자 개별 페이지가 각자 `section` 배경과 여백을 책임하면 콘텐츠가 짧은 화면에서 우측 배경이 끊겨 보인다. Ghost 관리자처럼 레이아웃의 우측 캔버스가 기본 화면 높이와 배경을 먼저 책임지게 하고, 사이드바는 320px 고정 폭으로 맞춰 목록·설정 화면의 기준 여백을 더 여유롭게 잡는다.
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.107
|
||||||
|
|
||||||
|
### 관리자 사이드바 Ghost형 톤 전환
|
||||||
|
|
||||||
|
기능 구현이 어느 정도 갖춰진 뒤에도 관리자 첫 화면의 어두운 사이드바는 오래된 CMS 느낌을 강하게 만들었다. Ghost 관리자처럼 밝은 바탕, 낮은 대비의 활성 행, 아이콘+라벨 내비게이션으로 바꾸고, 게시글 행 우측에 새 글 작성 `+` 버튼을 두어 목록을 거치지 않고 바로 작성으로 들어가게 했다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.105
|
||||||
|
|
||||||
|
### 네비게이션 기본 시드 중복 방지
|
||||||
|
|
||||||
|
`017_navigation_hierarchy.sql`에서 메뉴 계층 지원을 위해 `(location,label,url)` 유니크 제약을 제거한 뒤, 기존 `005_add_navigation_items.sql`의 `ON CONFLICT DO NOTHING`은 더 이상 동일 라벨·URL 기본 메뉴 중복을 막지 못했다. 개발 DB 마이그레이션은 전체 SQL 파일을 반복 실행하므로 기본 메뉴가 새 UUID로 누적될 수 있어, 시드 삽입을 `NOT EXISTS` 조건으로 바꾸고 `019_dedupe_navigation_items.sql`에서 기존 중복을 정리한 뒤 표현식 유니크 인덱스로 재발을 막는다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.104
|
||||||
|
|
||||||
|
### 관리자 권한 재검증과 마지막 소유자 보호
|
||||||
|
|
||||||
|
관리자 세션 쿠키는 서명과 만료만으로는 권한 변경·회원 탈퇴 이후 상태를 반영하지 못한다. `/admin/api/*` 요청마다 DB의 현재 `owner`/`admin` 권한을 다시 확인하는 서버 미들웨어를 추가해, 권한이 내려가거나 계정이 삭제된 세션은 즉시 차단한다. 회원 탈퇴는 마지막 `owner`를 없애지 못하도록 막고, 탈퇴 시 관리자 쿠키도 함께 정리한다.
|
||||||
|
|
||||||
|
### OTP 발송 실패와 초기 owner 판정 안정화
|
||||||
|
|
||||||
|
OTP는 메일 발송에 실패했는데 DB 챌린지만 남으면 사용자가 코드를 받지 못한 채 쿨다운에 걸릴 수 있다. 새 챌린지는 먼저 만들되 발송 실패 시 즉시 삭제하고, 발송 성공 후 이전 pending 챌린지를 정리한다. 첫 회원 생성은 동시에 들어온 요청이 모두 owner로 판정되지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.103
|
||||||
|
|
||||||
|
### map.md와 관리자 메뉴 화면 동기화
|
||||||
|
|
||||||
|
본문에서 마이그레이션 안내 블록을 제거했으므로 `docs/map.md` 설명에서도 해당 문구를 뺐다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.102
|
||||||
|
|
||||||
|
### 관리자 블록 에디터 빈 단락·키보드·슬래시 메뉴
|
||||||
|
|
||||||
|
Markdown 직렬화에서 연속 빈 줄은 사라져 중간 빈 단락을 유지하기 어렵다. 편집용으로만 `<!--sori:blank-paragraph-->` 한 줄을 쓰고 공개 렌더러에서 동일하게 빈 단락으로 복원한다. 슬래시 팔레트는 필터 변경 시 하이라이트를 초기화하고, 긴 목록은 스크롤·`scrollIntoView`로 따라가며, `/`가 아닐 때는 위아래 키로 블록 간 커서를 옮긴다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.101
|
||||||
|
|
||||||
|
### 공개 primary 네비 트리 중복 방지
|
||||||
|
|
||||||
|
`parent_id`가 있는 행과 루트로 들어온 동일 id가 공존하거나, 평면 목록에 id가 중복되면 한 루프로는 같은 노드가 `roots`와 부모 `children`에 동시에 들어갈 수 있다. 서버에서 id 단위로 한 번만 쓰고, 자식으로 연결된 id는 루트 후보에서 빼서 UI 중복을 제거한다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.100
|
||||||
|
|
||||||
|
### EMAIL_OTP_PEPPER 문서화
|
||||||
|
|
||||||
|
운영자가 짧은 숫자만 넣는 오해를 줄이기 위해, pepper는 세션 비밀과 별도로 OTP 해시에만 쓰이는 **긴 난수 문자열**임을 배포 문서·예시 env에 명시했다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.99
|
||||||
|
|
||||||
|
### 헤더 검색 중앙·Resend 이메일 OTP
|
||||||
|
|
||||||
|
헤더는 좌측 브랜드·우측 계정 사이에서 검색을 시각적 중심에 두는 편이 Ghost/Thred류 기대와 맞다. 트랜잭션 메일은 외부 SMTP 대신 Resend 단일 API로 운영 부담을 줄이고, 키가 없을 때는 기존 가입 흐름을 유지한다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.98
|
||||||
|
|
||||||
|
### 사이드바 푸터 링크 줄바꿈·상단 네비 호버 너비
|
||||||
|
|
||||||
|
`footer` 항목이 한 줄 `flex`로만 두면 좁은 사이드바에서 가로로 삐져나간다. `nav`에 `flex-wrap`과 `min-w-0 flex-1`을 주고 링크는 줄 단위로 감싸지게 했다. 상단 네비 행은 링크가 콘텐츠 너비만 차지하면 `site-panel-hover` 배경이 짧게 보이므로 `w-full`로 행 전체와 맞췄다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.97
|
||||||
|
|
||||||
|
### 상단 네비 장식·현재 페이지 표시
|
||||||
|
|
||||||
|
부모 행만 점으로 두면 리프와 시각 언어가 갈린다. `before` 세로바를 통일하고, 내부 활성 경로는 브랜드 악센트로 구분했다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.96
|
||||||
|
|
||||||
|
### 사이드바 상단 네비 폴더 토글 UX
|
||||||
|
|
||||||
|
chevron 전용 버튼과 펼침 배경은 원본 Ghost류 UI와 어긋난다. 부모 한 줄을 단일 컨트롤로 두고 chevron·패널 높이에 전환을 줘서 끊김을 줄였다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.95
|
||||||
|
|
||||||
|
### 메뉴 관리 단순화와 드래그 UX
|
||||||
|
|
||||||
|
`is_visible` 체크는 “숨기기” 용도였으나 목록에 두면 모두 표시해야 한다는 운영 기대와 맞지 않아 제거하고 항상 공개로 저장한다. `is_folder`는 자식 존재로만 서버가 채운다. 드래그는 태그 관리와 같은 행 단위 시각 피드백으로 맞췄다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.94
|
||||||
|
|
||||||
|
### 메뉴 관리 UX와 상단 네비 트리
|
||||||
|
|
||||||
|
위치를 셀렉트로 바꾸면 실수로 상·하단을 오가기 쉽고, 인덱스 입력은 모달·레이아웃과 겹칠 때 피드백이 약하다. 미디어처럼 탭으로 영역을 나누고 순서는 드래그로 통일했다. Ghost형 상단 그룹은 `parent_id`와 공개 트리 API, 사이드바에서 chevron 접기로 맞췄다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.93
|
||||||
|
|
||||||
|
### 관리자 미디어 오류 표시를 토스트로
|
||||||
|
|
||||||
|
상세·폴더 모달이 `z-50`~`z-[60]`일 때 본문 상단 배너는 모달 뒤에 깔려 사용자가 API 오류(예: 동일 파일명 409)를 볼 수 없었다. `useAdminToast`로 우측 상단 고정·높은 z-index에 두어 레이아웃과 무관하게 피드백이 보이게 했다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.92
|
||||||
|
|
||||||
|
### 프로필 썸네일 해제와 다운로드
|
||||||
|
|
||||||
|
서버는 이미 디스크를 지우지 않지만, 설정 화면이 `PUT /api/auth/profile`로만 `avatarUrl`을 비울 때는 메타 분리가 빠져 관리자 목록과 체감이 어긋날 수 있어 `DELETE /api/auth/avatar`와 같은 `removeManagedAvatarAsset` 호출을 맞췄다. 관리자 미디어 모달에 다운로드를 넣어 원본 확인을 쉽게 했다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.91
|
||||||
|
|
||||||
|
### 썸네일 미사용 자산과 업로드 파일명
|
||||||
|
|
||||||
|
프로필에서 바뀐 옛 썸네일을 바로 디스크에서 지우면 관리자가 누가 올린 자산인지 추적하기 어렵다. 메타만 끊고 파일은 남겨 썸네일 탭에서 정리하도록 바꿨다. 삭제·이름 변경 차단은 `avatar_url`이 가리키는 경우로 한정했다. 게시물 업로드는 UUID 접미 대신 원본명과 넘버링으로 검색 가능성을 높였다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.90
|
||||||
|
|
||||||
|
### 관리자 미디어 라이브러리와 썸네일 탭 분리
|
||||||
|
|
||||||
|
게시물 이미지는 디스크상 `posts/`에 두더라도 논리 분류는 `미분류`로 통일해 `posts` 트리와 이중 표기를 없앤다. 회원 프로필 이미지는 디스크 경로는 유지하되 논리 폴더를 예약명 `썸네일`로 고정하고, 관리자 화면에서는 탭을 나눠 검색·탐색 대상을 분리했다. 썸네일 파일은 URL이 회원 콘텐츠와 직결되므로 관리자에서 임의 삭제·이름 변경이 되면 프로필이 깨지기 쉬워 API·UI에서 막는다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.89
|
||||||
|
|
||||||
|
### 미디어 선택 토글 가시성
|
||||||
|
|
||||||
|
네이티브 체크박스는 배경·브라우저 기본 스타일 때문에 흰 썸네일 위에서 거의 보이지 않는 사례가 있어, 동일 hit 영역을 유지한 채 명암이 큰 커스텀 토글로 바꿨다. 폴더 트리에 나오는 `posts`·`미분류` 등은 디스크 경로와 DB 메타 규칙을 문서에 적어 운영자가 혼동하지 않도록 했다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.88
|
||||||
|
|
||||||
|
### 관리자 미디어 선택·폴더 UX
|
||||||
|
|
||||||
|
썸네일 전체 클릭이 선택과 미리보기를 겹쳐 쓰기 어렵다. 본문 클릭은 미리보기 모달, 좌측 체크박스만 선택으로 분리했다. 폴더 추가는 사용 빈도가 낮아 상시 입력 대신 모달로 받고, 비어 있지 않은 분류 트리를 위해 폴더 삭제 API와 행 단위 삭제를 추가했다(물리 파일은 건드리지 않고 메타만 미분류로).
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.87
|
||||||
|
|
||||||
|
### 저장·로그인 버튼 기본 비활성과 글 목록 삭제 아이콘
|
||||||
|
|
||||||
|
정렬 저장·메뉴 저장은 변경이 없을 때 연속 클릭이 의미 없으므로, 서버 스냅샷과 비교해 dirty일 때만 활성화한다. 로그인은 빈 제출을 막기 위해 필수 필드가 채워진 뒤에만 버튼을 켠다. 글 목록 삭제는 보조 액션이므로 텍스트 강조 대신 휴지통 아이콘과 낮은 기본 대비, 호버 시 강조로 시선 부담을 줄였다.
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.86
|
||||||
|
|
||||||
|
### 게시물 URL 로마자화와 태그 표기 분리
|
||||||
|
|
||||||
|
게시물 슬러그는 URL 안정성을 위해 한글 음절을 로마자로 바꾸는 기존 방식을 유지한다. 반면 태그는 사용자가 입력한 한글을 그대로 쓰는 경우가 많고, 동일 로마자화를 적용하면 배지·DB `name`이 기대와 달라지므로 태그 토큰은 한글·영문·숫자와 하이픈만 정리하는 별도 정규화로 분리했다. 저장소에서는 태그 슬러그에 한글이 포함되면 하이픈을 공백으로 바꾼 문자열을 표시명으로 쓰고, 순수 라틴 하이픈 슬러그는 기존처럼 단어별 이니셜 대문자 규칙을 유지한다.
|
||||||
|
|
||||||
|
### 관리자 글 SEO 입력 단순화
|
||||||
|
|
||||||
|
공개 상세는 이미 SEO 필드가 비어 있으면 제목·요약을 메타 기본값으로 쓰므로, 관리자 폼에서 별도 SEO 제목·설명 입력을 두면 중복 편집만 늘어난다. 저장 시점에 제목·요약을 `seo_title`·`seo_description`에 동기화하고 폼에서는 `noindex`만 노출해 입력 부담을 줄였다.
|
||||||
|
|
||||||
|
### 태그 관리 피드백을 토스트로
|
||||||
|
|
||||||
|
순서 저장 등 성공 메시지를 본문 위에 블록으로 넣으면 레이아웃이 밀려 체감 품질이 떨어지므로, 네비게이션 저장과 동일하게 우측 상단 고정 토스트로 통일했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.85
|
||||||
|
|
||||||
|
### 의도한 빈 문단 저장 보존
|
||||||
|
|
||||||
|
블록 에디터는 마지막 보조 빈 문단을 자동으로 유지하는 구조라서, 저장 시 모든 빈 문단을 제거하면 사용자가 의도적으로 만든 2~3줄 공백도 함께 사라진다. 이를 구분하기 위해 빈 문단 전용 마커를 저장 포맷에 도입하고, 에디터 파서와 공개 렌더러 파서가 동일하게 해석하도록 맞춰 공백 의도를 보존했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.84
|
||||||
|
|
||||||
|
### 방향키 문단 이동과 슬래시 메뉴 스크롤 고정
|
||||||
|
|
||||||
|
관리자 에디터는 블록 단위 편집이므로 일반 텍스트 에디터처럼 위/아래 방향키로 인접 문단으로 자연스럽게 넘어가야 한다. 커서가 블록 경계에 있을 때만 인접 블록으로 이동하도록 보완해 기존 블록 내부 이동과 충돌을 줄였다. 슬래시 메뉴는 명령 수가 많아도 화면을 넘기지 않도록 최대 높이+내부 스크롤로 제한하고, 방향키 하이라이트 항목을 항상 가시 영역으로 자동 스크롤해 선택 맥락을 유지하도록 정리했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.83
|
||||||
|
|
||||||
|
### 슬래시 메뉴 방향키 상태 유지
|
||||||
|
|
||||||
|
슬래시 메뉴 강조 인덱스를 검색어 동기화 때마다 0으로 초기화하면 방향키를 여러 번 눌러도 체감상 1회만 이동하는 것처럼 보이므로, 검색어가 바뀐 경우에만 초기화하도록 분리했다. 또한 슬래시 입력 상태가 아닌 일반 본문 블록에서는 메뉴 방향키 핸들러를 즉시 빠져나오게 해 기본 커서 이동 동작을 최대한 유지하도록 조정했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.82
|
||||||
|
|
||||||
|
### 메인 태그는 강등, 일반 태그는 검색 삭제
|
||||||
|
|
||||||
|
메인 태그는 홈 카테고리 노출 자산이므로 목록에서 실수 삭제보다 일반 태그로 되돌리는 강등 동작이 안전하다고 판단했다. 반대로 일반 태그는 수량이 많아 전체 목록 노출 대신 검색 중심으로 다루고, 삭제도 검색 결과 문맥에서만 허용해 운영 화면의 복잡도를 줄였다. 태그 수정 화면의 정렬/유형 입력은 목록 액션과 역할이 겹치므로 제거했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.81
|
||||||
|
|
||||||
|
### 태그 입력 IME 안정화와 메인 태그 전환 흐름
|
||||||
|
|
||||||
|
관리자 글 작성 태그 입력은 한글 조합 중 Enter 이벤트가 완성 전/완성 후로 중복 처리될 수 있어, 조합 상태에서는 태그 추가를 막고 조합 완료 후에만 확정하도록 정리했다. 또한 게시물 작성에서 자연스럽게 늘어나는 태그는 기본적으로 일반 태그로 생성하고, 카테고리로 노출할 태그만 별도 검색 후 메인 태그로 승격하도록 운영 흐름을 분리했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.80
|
||||||
|
|
||||||
|
### 태그를 관리용/일반용으로 분리하고 관리용만 정렬
|
||||||
|
|
||||||
|
태그 수가 많아질수록 모든 태그에 순번을 강제하면 운영 비용이 커지므로, 홈페이지 카테고리로 쓰는 태그만 `managed`로 분리해 순서를 관리하고 나머지는 `general`로 분리했다. 관리용 태그는 드래그 앤 드롭으로 순서를 바꾸고 일괄 저장 API로 반영해 중복 숫자 입력 문제를 제거했다.
|
||||||
|
공개 `GET /api/tags`는 관리용 태그만 반환하도록 바꿔, 카테고리 노출 목적과 일반 배지 태그 목적이 섞이지 않게 했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.79
|
||||||
|
|
||||||
|
### 최초 사용자 관리자 부트스트랩 전환
|
||||||
|
|
||||||
|
관리자 계정을 환경변수로 고정하면 실제 운영에서 초기 세팅 흐름이 불명확하고, 관리자 프로필/권한 관리가 회원 데이터와 분리되어 확장성이 떨어진다. 따라서 최초 사용자가 회원가입을 시도하는 시점에 `관리자 등록` 모드로 안내하고, 첫 계정에 `is_admin=true`를 부여하는 부트스트랩 방식으로 전환했다. 관리자 로그인도 동일한 `users` 인증 체계를 사용하도록 맞춰, 관리자/회원 계정 모델을 일원화했다.
|
||||||
|
관리자 화면에서 썸네일/이름을 바로 수정할 수 있도록, 관리자 로그인 시 회원 세션도 함께 발급해 기존 회원 프로필 API를 재사용하는 방향을 선택했다.
|
||||||
|
권한은 향후 기능 확장을 위해 `owner`/`admin`/`member` 3단계로 먼저 분리하고, 현재 단계에서는 관리자 멤버 화면에서 권한 값을 변경할 수 있게 준비했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.79
|
||||||
|
|
||||||
|
### 댓글 아바타/좋아요/상대시간 표시 정렬
|
||||||
|
|
||||||
|
댓글 영역은 텍스트 중심 구조만으로는 SNS형 피드백 흐름이 약해 참여 지표를 확인하기 어려웠다. 작성자 썸네일과 좋아요 토글을 기본 액션으로 배치하고, 시간 표기는 최근 24시간 동안 상대 시간으로 보여 즉시성을 높였다. 24시간 이후에는 날짜로 전환해 장기 글에서도 타임라인 문맥을 유지한다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.78
|
||||||
|
|
||||||
|
### 관리자 미디어에서 회원 썸네일 가시성 복구
|
||||||
|
|
||||||
|
회원 썸네일을 미디어 목록에서 완전히 제외하면 운영자가 업로드 결과를 확인하거나 정리할 수 없어 관리성이 떨어진다. 경로 분리는 유지하되 관리자 미디어에서는 `회원/썸네일` 카테고리로 조회되도록 바꿔, 일반 콘텐츠 미디어와 논리적으로 구분하면서도 관리 화면에서 추적 가능하게 했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.77
|
||||||
|
|
||||||
|
### 회원 설정 썸네일 표시 방식 전환
|
||||||
|
|
||||||
|
설정 화면에서 썸네일 URL 문자열을 직접 보여 주는 방식은 실제 사용자에게 의미가 낮고, 업로드 결과를 직관적으로 확인하기 어려웠다. 프로필 카드에서 아바타 미리보기와 이미지 변경/제거 액션을 바로 제공해 확인-수정 흐름을 단순화했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.76
|
||||||
|
|
||||||
|
### 회원 썸네일 중앙 1:1 강제 크롭
|
||||||
|
|
||||||
|
회원이 세로형/가로형 이미지를 올려도 헤더와 설정 화면의 아바타는 동일한 비율이어야 UI가 안정적이므로, 업로드 시점에 중앙 기준으로 1:1 정사각형 크롭을 강제했다. 이렇게 하면 클라이언트별 개별 크롭 로직 없이 서버 저장본 자체가 일관된 아바타 규격을 가진다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.75
|
||||||
|
|
||||||
|
### 회원 썸네일 최소 해상도/설정 방어 강화
|
||||||
|
|
||||||
|
회원 썸네일은 너무 작은 원본이 올라오면 헤더/설정 화면에서 품질 저하가 크게 보이므로 최소 해상도 제한을 추가했다. 또한 운영 환경 변수 오입력으로 리사이즈/품질 값이 비정상이어도 업로드가 깨지지 않도록 서버에서 값 범위를 보정(clamp)해 안정성을 높였다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.74
|
||||||
|
|
||||||
|
### 회원 썸네일 업로드 표준화(WebP + 리사이즈)
|
||||||
|
|
||||||
|
회원 아바타는 사용자 입력 원본 포맷과 해상도가 제각각이므로, 업로드 시점에 서버에서 WebP로 통일 변환하고 최대 가로/세로를 제한해 저장하기로 했다. 이 방식은 저장 공간과 전송 용량을 줄이고, 너무 큰 원본 이미지 업로드로 인한 렌더링·트래픽 비용 증가를 줄이면서도 기존 URL 분리 정책(`/uploads/members/avatars/`)을 유지할 수 있다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.73
|
||||||
|
|
||||||
|
### 회원 썸네일 생명주기 정리
|
||||||
|
|
||||||
|
회원 아바타는 교체가 빈번해 파일이 누적되기 쉬우므로, 업로드 성공 후 이전 회원 전용 썸네일 자산을 자동 정리하도록 했다. 또한 회원이 직접 썸네일을 제거하거나 탈퇴할 때도 동일한 정리 로직을 재사용해 고아 파일과 불필요한 `media_metadata` 레코드가 남지 않게 했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.72
|
||||||
|
|
||||||
|
### 회원 썸네일 미디어 분리
|
||||||
|
|
||||||
|
회원 썸네일은 운영자(작가) 콘텐츠 제작용 미디어와 목적이 다르므로, 업로드 경로를 `/uploads/members/avatars/YYYY/MM`로 분리했다. 관리자 미디어 목록에서는 해당 경로를 숨겨, 작가용 미디어 라이브러리와 회원 프로필 자산이 섞이지 않도록 했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.71
|
||||||
|
|
||||||
|
### 회원 UX를 헤더 중심으로 전환
|
||||||
|
|
||||||
|
기존 헤더는 로그인 여부와 무관하게 Anonymous 메뉴를 고정으로 보여 실제 로그인 상태가 사용자에게 전달되지 않았다. 구독 버튼 대신 우측 아바타만 남기고, 로그인 상태에서는 설정/로그아웃 메뉴를 제공해 계정 액션을 한 위치로 정리했다. 비로그인 상태에서는 기존 Sign up/Sign in을 유지한다.
|
||||||
|
|
||||||
|
### 회원 설정/활동 추적과 관리자 멤버 관측
|
||||||
|
|
||||||
|
회원 기능이 들어오면서 운영 관점에서 사용자 정보와 활동 추적이 필요해졌다. `users`에 `avatar_url`, `last_seen_at`, `last_seen_ip`를 추가하고 로그인/세션조회/댓글작성 시 최근 활동을 갱신한다. 관리자는 `/admin/members`에서 닉네임, 이메일, 최근 접속, IP, 댓글 수를 확인해 운영 판단을 할 수 있다.
|
||||||
|
|
||||||
|
### 닉네임 유니크 정책
|
||||||
|
|
||||||
|
사용자 설정에서 닉네임 변경 시 중복 체크가 필요하므로 DB 레벨에서 `lower(username)` 유니크 인덱스를 도입했다. 기존 중복 데이터로 마이그레이션이 막히지 않도록, 인덱스 생성 전 중복 닉네임은 `-2`, `-3` 접미사를 붙여 자동 정리한 뒤 인덱스를 생성한다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.65
|
||||||
|
|
||||||
|
### 통합 검색 모달과 `GET /api/search`
|
||||||
|
|
||||||
|
헤더 검색은 장식이 아니라 Ghost류 UX로 `/` 단축키·모달·태그·게시물 섹션 구분이 필요했다. `INPUT`/`TEXTAREA` 등에 포커스가 있을 때는 브라우저 입력과 충돌하지 않도록 `/`를 무시한다. 검색은 저장소 `searchPublicContent`에 모아 `LIKE` 대신 `position(lower(q) in lower(column))`로 부분 일치를 구현해 `%`·`_` 이스케이프 이슈를 줄였다. 저자(author) 검색은 현재 도메인 모델에 없어 제외했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.63
|
||||||
|
|
||||||
|
### Tailwind 엔트리 단일화
|
||||||
|
|
||||||
|
`@nuxtjs/tailwindcss` 기본 `cssPath`는 `assets/css/tailwind.css`인데 저장소에 해당 파일이 없으면 모듈이 패키지 내 `tailwind.css`를 `nuxt.options.css` 앞에 끼워 넣는다. 프로젝트는 이미 `main.css`에 `@tailwind`와 커스텀 `@layer`를 두고 있어 두 엔트리가 겹치면 유틸·레이어 순서가 기대와 달라질 수 있다. `tailwindcss.cssPath`를 `main.css`로 고정하고, JIT `content`에 composables·modules·plugins를 포함해 클래스 수집을 보강했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.62
|
||||||
|
|
||||||
|
### 인증 폼 다크 스타일이 안 보이던 현상
|
||||||
|
|
||||||
|
`layout/page.vue`의 `text-ink`는 본문에 전달되지만, 폼 컨트롤은 UA 스타일로 `color`를 상속하지 않는 경우가 많아 다크 배경에서 입력 글자와 `currentColor` SVG가 사실상 사라질 수 있다. 전역 `.auth-form-input`으로 텍스트·캐럿·placeholder·WebKit autofill 글자색을 고정하고, 토글 버튼은 SFC `scoped` 스타일로 동일하게 맞췄다. `color-scheme: dark`는 네이티브 컨트롤 테마를 맞추기 위해 섹션에 추가했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.61
|
||||||
|
|
||||||
|
### 인증 폼 비밀번호 토글 아이콘화
|
||||||
|
|
||||||
|
보기/숨기기 텍스트는 좁은 모바일에서 시각적 잡음이 되고 다국어·아이콘 일관성도 떨어져, Material 스타일 단일 경로 SVG(눈 열림·가림)를 공통 컴포넌트로 두었다. `aria-label`은 필드명(`field-name`)을 받아 회원가입의 확인 필드와 구분한다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.60
|
||||||
|
|
||||||
|
### 홈 Featured 모바일 스크롤·화살표 상태
|
||||||
|
|
||||||
|
가로 오버플로 트랙은 기본적으로 스크롤 가능하지만, 카드 전체가 링크일 때 브라우저가 세로 제스처에 가깝게 해석하거나 체인 스크롤이 나는 경우가 있어 `touch-pan-x`와 `overscroll-x-contain`으로 가로 우선·부모 스크롤 전파를 줄였다. 화살표는 스크롤 한계에서 의미 없는 클릭을 막기 위해 `scrollLeft`와 `scrollWidth - clientWidth` 비교로 `disabled`를 두고, 레이아웃 변화에 맞추기 위해 `ResizeObserver`를 함께 썼다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.59
|
||||||
|
|
||||||
|
### Nuxt `#internal/nuxt/paths` Node 해석 오류
|
||||||
|
|
||||||
|
Nuxt 3.21과 `@nuxt/vite-builder`는 SSR 엔트리에서 `#internal/nuxt/paths`를 롤업 외부 모듈로 남기는데, 동일 경로의 `paths.mjs` 템플릿은 기본적으로 VFS에만 있어 디스크 파일이 없다. Node는 프로젝트 루트 `package.json`의 `imports`로만 서브패스를 해석하므로, 템플릿을 디스크에 쓰도록(`write: true`) 훅하는 로컬 모듈과 루트 `imports` 매핑을 추가했다. `nitro-server` 경로만으로 브리지하면 `nitropack/runtime` 쪽 내부 specifier가 끌려와 단독 해석이 깨지므로, Nuxt가 생성하는 `paths.mjs` 본문을 그대로 두는 방식을 택했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.58
|
||||||
|
|
||||||
|
### 중앙 본문과 우측 사이드 가로 넘침
|
||||||
|
|
||||||
|
그리드에 `lg:px-5` 등 패딩이 있는데 열 정의가 `287px + 720px + 287px`로 합이 `max-width`와 맞춰져 있으면, 패딩을 제외한 실제 가용 폭보다 열 합이 커지고 `main`에 `width: 720px`가 걸려 있으면 중앙 열이 줄어들지 못해 오른쪽으로 삐져 나간다. 중앙 트랙을 `minmax(0,1fr)`로 두고 본문은 `max-width: 720px`로만 제한했으며, 열 사이 `column-gap`으로 우측 사이드와의 간격을 명시했다. 좌측 메뉴를 접었을 때는 `gap`이 왼쪽에 빈 여백을 만들지 않도록 끄고, 대신 본문에만 우측 패딩을 준다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.57
|
||||||
|
|
||||||
|
### 사이드바 하단 푸터 여백
|
||||||
|
|
||||||
|
Thred 참고 레이아웃에서 본문 블록은 `pl-4` 등으로 호흡이 있는데, 좌측 사이드 푸터만 `px-1`로 두어 푸터 링크가 시각적으로 왼쪽 벽에 붙어 보였다. 푸터는 스크롤 영역과 동일한 체감 밀도가 나오도록 `px-4` 이상으로 올리고, 우측 사이드 카피라이트 줄에도 소폭 `pr`을 맞춰 패널 경계와의 간격을 통일했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.56
|
||||||
|
|
||||||
|
### 헤더 좁은 데스크톱 폭에서의 밀집 완화
|
||||||
|
|
||||||
|
`lg` 직후(약 1024~1280px)에서 검색창이 고정 470px이면 3단 그리드와 헤더 액션이 같은 뷰포트 안에서 경쟁해 아이콘과 버튼이 시각적으로 붙는다. 검색창은 `flex-1`과 구간별 `max-w`로 축소 가능하게 하고, 헤더·본문 그리드에 `lg`~`xl` 수평 패딩을 복구해 Thred형 3열을 유지하면서도 호흡을 확보했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.55
|
||||||
|
|
||||||
|
### 공개 레이아웃 모바일 분기
|
||||||
|
|
||||||
|
`lg` 미만에서는 3열 그리드 대신 세로 흐름으로 본문을 먼저 보여 주고, 오른쪽 사이드는 본문 아래로 내린다. 왼쪽 내비는 화면 폭을 줄였을 때 본문을 밀어내지 않도록 고정 슬라이드 패널로 띄우고, 백드롭 클릭·Escape·헤더 토글로 닫을 수 있게 했다. 데스크톱에서는 기존처럼 그리드 3열과 스티키 사이드바를 유지한다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.54
|
||||||
|
|
||||||
|
### 공개 인증 화면 가독성과 입력 피드백 보정
|
||||||
|
|
||||||
|
회원가입/로그인은 현재 백엔드 인증 연동 전 단계이므로, 사용자가 실제 동작 상태를 오해하지 않도록 화면 피드백을 더 명확히 보여주는 것이 우선이라고 판단했다. 회원가입은 모바일 우선 여백과 카드 패널 레이아웃으로 읽기 흐름을 정리하고, 로그인 화면은 오류 메시지와 안내 메시지를 분리해 의미가 섞이지 않게 했다.
|
||||||
|
|
||||||
|
로그인 입력에서는 비밀번호 보기/숨기기 토글을 추가해 모바일 환경에서도 오입력을 줄일 수 있게 했다. 회원가입 2단계와 로그인 화면에는 상호 이동 링크를 보강해 사용자 흐름이 한 화면에서 끊기지 않도록 정리했다.
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.53
|
||||||
|
|
||||||
|
### 게시물 공유 모달 UI
|
||||||
|
|
||||||
|
게시물 상세의 제목 오른쪽 공유 버튼은 단순 아이콘만 두지 않고, 모달에서 공유 미리보기 카드와 채널별 링크를 제공하도록 확장했다. 사용자가 외부 공유 전 게시물 정보(썸네일·제목·요약)를 한번 확인할 수 있고, 링크 복사까지 같은 컨텍스트에서 끝낼 수 있어 Thred 참고 UX와 운영 편의성을 함께 맞출 수 있기 때문이다.
|
||||||
|
|
||||||
|
### 헤더 사용자 메뉴 단순화
|
||||||
|
|
||||||
|
헤더 우측은 Account 텍스트 링크 대신 아바타 아이콘 버튼으로 전환하고, 비로그인 기준 드롭다운 메뉴에서 Sign up/Sign in만 제공한다. 다크 모드나 메뉴 열기 토글은 이미 헤더와 사이드바에 노출되어 기능이 중복되므로 사용자 메뉴에서는 제거해 정보 밀도를 낮췄다.
|
||||||
|
|
||||||
|
### 회원가입/로그인 공개 화면 초안
|
||||||
|
|
||||||
|
회원가입은 `/signup` 단일 화면에서 3단계(환영, 정보 입력, 이메일 확인) 플로우로 처리한다. 초기 단계에서는 실제 메일 인프라 연결 전이므로 3단계에서 인증 메일 재전송과 인증 완료 액션을 시뮬레이션하고, 인증 완료 후 로그인 화면으로 이동시키는 흐름을 먼저 고정한다. 로그인 화면(`/signin`)은 같은 다크 톤 레이아웃으로 맞춰 인증 화면군의 시각 일관성을 유지한다.
|
||||||
|
|
||||||
|
회원가입 스텝 인디케이터는 단계별 콘텐츠 높이에 따라 위치가 바뀌지 않도록, 화면 높이를 기준으로 한 `min-h` 레이아웃의 하단 고정 영역에 둔다. 회원가입 1단계 환영 문구는 하드코딩 대신 사이트 설정 API의 `title`, `description` 값을 사용해 추후 블로그 이름/인사말 관리 화면과 자연스럽게 연결한다.
|
||||||
|
|
||||||
|
## 2026-05-08 v0.0.52
|
||||||
|
|
||||||
|
### 목록 Featured 아이콘 정렬과 상세 메타 구분자
|
||||||
|
|
||||||
|
홈/태그 목록에서 Featured 아이콘을 제목 텍스트 라인에 단순 `inline-flex`+음수 마진으로 올리면, 특정 폰트 렌더링에서 라인박스 높이가 달라져 카드 높이가 미묘하게 흔들릴 수 있다. 아이콘을 `h-4 w-4` 고정 박스로 만들고 `items-center`로 정렬해 제목 줄 높이를 안정화했다. 게시물 상세의 제목 아래 메타 정보는 원본 스킨처럼 `/` 구분자를 매번 수동으로 넣지 않고, 래퍼에서 `after` 규칙으로 일관되게 출력하도록 통일했다.
|
||||||
|
|
||||||
|
## 2026-05-08 v0.0.51
|
||||||
|
|
||||||
|
### 사이드바 고정 높이와 발행일 포맷
|
||||||
|
|
||||||
|
`lg+` 그리드에서 `items-start` 때문에 사이드바 박스 높이가 콘텐츠만큼만 잡히면 내부 `flex-1` 스크롤 영역이 늘어나지 않아 푸터가 상단 블록 바로 아래에 붙는다. 데스크톱에서 열 높이를 `h-[calc(100vh-57px)]`(및 동일 `max-h`)로 고정해 flex 컬럼 안에서 푸터를 열 하단에 두었다. 공개 피드·상세의 발행일은 `formatPostDate`로 `YYYY.MM.DD`를 통일하고 `<time datetime>`에 원본 ISO를 넣어 접근성을 맞춘다.
|
||||||
|
|
||||||
|
## 2026-05-08 v0.0.50
|
||||||
|
|
||||||
|
### 문서 스크롤과 스티키 사이드바
|
||||||
|
|
||||||
|
Thred에 가깝게 본문 길이에 따른 세로 스크롤은 `main`의 `overflow-y`가 아니라 **뷰포트(문서) 스크롤**로 통일한다. 스크롤바가 중앙 열에만 생기는 체감을 피하고 브라우저 기본 스크롤 위치와 맞추기 위함이다. 좌·우 사이드는 `position:sticky`와 `max-height: calc(100vh - 헤더)`로 고정감을 유지하고, 사이드 내용이 넘칠 때는 스크롤바를 숨긴 채 내부만 스크롤한다.
|
||||||
|
|
||||||
|
## 2026-05-08 v0.0.49
|
||||||
|
|
||||||
|
### 데스크톱 3단 레이아웃 스크롤 영역 분리
|
||||||
|
|
||||||
|
Thred 참고 화면처럼 긴 본문이 있을 때도 좌·우 사이드 하단(푸터)이 뷰포트 기준으로 고정되도록, 그리드 행 높이를 `calc(100vh - 헤더)`로 제한하고 `grid-template-rows: minmax(0,1fr)`로 자식이 넘치지 않게 한다. 세로 스크롤은 중앙 `main`에만 두어 사이드바는 내부 스크롤 영역과 고정 푸터로 나눈다.
|
||||||
|
|
||||||
|
## 2026-05-08 v0.0.48
|
||||||
|
|
||||||
|
### Twitter/X 공식 embed iframe과 북마크·회원가입 마크다운 블록
|
||||||
|
|
||||||
|
공개 본문에서 트위터 게시물은 `platform.twitter.com/embed/Tweet.html` iframe으로 표시한다. oEmbed API나 스크립트 삽입에 비해 구현이 단순하고 SSR·테마(`useThemeMode`)와 `theme` 쿼리만 맞추면 라이트/다크 일관성을 유지하기 쉽기 때문이다.
|
||||||
|
|
||||||
|
북마크·뉴스레터 CTA는 Ghost/Thred 스킨에서 흔한 카드 패턴이므로 `:::bookmark`·`:::signup` 확장 블록으로 저장하고 전용 Vue 컴포넌트로 렌더링한다. 메타데이터 풍부한 북마크는 추후 oEmbed나 서버 페치로 보강할 수 있도록 마크다운 키값 형식을 병행한다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.45
|
||||||
|
|
||||||
|
### 사용자 화면 단일 배경과 사이드바 전환 방식 결정
|
||||||
|
|
||||||
|
사용자 화면 라이트 모드는 전체 배경을 `#fcfcfc`로 통일하고, 영역 구분은 색상 차이가 아니라 보더로만 처리한다. Thred 참고 화면처럼 배경 톤 편차를 줄이면 카드, 사이드바, 본문이 하나의 캔버스 안에서 정돈되어 보이고 시선이 콘텐츠와 타이포에 더 집중되기 때문이다.
|
||||||
|
|
||||||
|
왼쪽 사이드바는 열림/닫힘 시 DOM을 제거하지 않고 너비를 애니메이션으로 줄인다. 사이드바가 즉시 사라지면 레이아웃이 튀어 보이므로, 그리드 컬럼과 사이드바 폭을 함께 전환해 스르륵 접히는 느낌을 유지한다.
|
||||||
|
|
||||||
|
왼쪽 네비게이션 항목은 기본 상태에서 회색 세로 바를 보이고 hover/focus 시 원형 아이콘으로 전환한다. 정적 상태에서는 구분선을 제공하고 상호작용 시 클릭 가능 영역을 명확하게 드러내기 위해서다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.44
|
||||||
|
|
||||||
|
### 사용자 화면 테마 상태 저장과 샘플 폴더 제외 결정
|
||||||
|
|
||||||
|
사용자 화면 라이트/다크 모드는 시스템 테마 자동 감지에만 의존하지 않고 수동 전환 상태를 `localStorage.SITE_THEME`에 저장한다. 공개 화면 헤더와 사이드바에서 같은 테마 상태를 공유해야 하며, 다음 방문에서도 사용자가 마지막으로 선택한 테마를 유지해야 하기 때문이다.
|
||||||
|
|
||||||
|
테마 색상 적용은 CSS 변수와 `html[data-theme]` 조합으로 처리한다. 기존 `prefers-color-scheme`는 기본 fallback으로 유지하되, 사용자가 명시적으로 라이트를 고른 경우 시스템이 다크여도 의도한 화면이 유지되도록 우선순위를 분리한다.
|
||||||
|
|
||||||
|
Thred 참고용 샘플인 `ZCF-v1.0.5`와 보관 폴더는 레퍼런스 자료로만 사용하고 Git 추적 대상에서 제외한다. 대용량 정적 자산과 외부 테마 원본이 변경 이력에 섞이면 실제 서비스 코드 변경 검토가 어려워지기 때문이다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.43
|
||||||
|
|
||||||
|
### 대표 이미지 액션과 선택 확정 흐름 결정
|
||||||
|
|
||||||
|
대표 이미지가 이미 설정된 상태의 변경과 삭제 액션은 이미지 아래 별도 영역이 아니라 이미지 hover 오버레이로 표시한다. 실제 공개 화면에서 보일 이미지 비율과 편집용 버튼 영역이 섞이면 작성자가 레이아웃을 잘못 인식할 수 있기 때문이다.
|
||||||
|
|
||||||
|
대표 이미지 선택 모달에서는 미디어 클릭 즉시 값을 바꾸지 않고 선택 상태만 표시한 뒤, 하단 대표 이미지로 적용 버튼으로 확정한다. 변경 작업은 실수했을 때 되돌리기보다 확정 전 확인이 더 안전한 흐름이기 때문이다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.42
|
||||||
|
|
||||||
|
### 태그 입력과 대표 이미지 선택 흐름 결정
|
||||||
|
|
||||||
|
관리자 글 설정의 태그 입력은 단순 텍스트 필드가 아니라 배지형 입력으로 처리한다. 태그 입력 중 Enter가 폼 제출로 전파되면 의도치 않게 게시물이 저장 또는 발행될 수 있으므로, Enter와 쉼표는 태그 추가 동작으로만 사용한다.
|
||||||
|
|
||||||
|
Canonical URL과 OG 이미지는 별도 입력 항목에서 제외한다. 현재 운영 흐름에서는 기본 글 주소와 대표 이미지가 자연스러운 기본값이며, 별도 OG 이미지를 관리하면 글 설정 패널이 불필요하게 길어지고 대표 이미지와 공유 이미지가 어긋날 수 있기 때문이다.
|
||||||
|
|
||||||
|
대표 이미지는 업로드 탭과 미디어 라이브러리 탭을 함께 제공한다. 작성자는 새 이미지를 바로 올릴 수도 있고, 이미 업로드한 이미지를 재사용할 수도 있어야 하기 때문이다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.41
|
||||||
|
|
||||||
|
### 명령 메뉴 계층과 개발 도구 표시 결정
|
||||||
|
|
||||||
|
관리자 블록 에디터의 `/` 명령 메뉴가 열린 행은 다른 블록 행보다 위 stacking 순서로 올린다. 메뉴가 절대 위치로 열릴 때 아래 블록의 텍스트가 같은 레이어에 남아 있으면 메뉴 배경 위로 겹쳐 보일 수 있기 때문이다.
|
||||||
|
|
||||||
|
블록 이동은 `drop` 이벤트뿐 아니라 `dragend`에서도 현재 삽입선 위치를 기준으로 확정한다. 브라우저와 입력 요소 조합에 따라 contenteditable 주변에서 `drop` 이벤트가 안정적으로 들어오지 않을 수 있으므로, 사용자가 본 삽입선과 실제 결과가 어긋나지 않게 하기 위해서다.
|
||||||
|
|
||||||
|
개발 서버의 Nuxt DevTools는 현재 관리자 글쓰기 전체 화면 QA를 방해하므로 기본 비활성화한다. 하단 검은 도킹 패널은 애플리케이션 UI가 아니라 개발 도구 영역이지만, 편집 화면 높이와 스크롤 문제를 확인할 때 혼동을 만들 수 있기 때문이다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.40
|
||||||
|
|
||||||
|
### 글쓰기 스크롤과 드래그 드롭 피드백 결정
|
||||||
|
|
||||||
|
관리자 글 작성/수정 화면은 글쓰기 라우트에서 관리자 레이아웃 자체를 화면 높이로 고정하고, 실제 세로 스크롤은 에디터 작업 영역 내부에서만 처리한다. 작성 화면 아래로 문서 전체가 밀리면 전역 다크 배경이 노출될 수 있고, Ghost 스타일 전체 화면 편집 모드의 집중감도 깨지기 때문이다.
|
||||||
|
|
||||||
|
블록 드래그 이동은 대상 블록 위/아래 절반을 기준으로 삽입 위치를 계산하고 같은 위치에 삽입선을 표시한다. 사용자가 놓는 순간의 결과를 드롭 전에 알 수 있어야 블록형 에디터의 이동 조작이 안전하게 느껴지기 때문이다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.39
|
||||||
|
|
||||||
|
### 블록 에디터 줄바꿈과 핸들 표시 보정 결정
|
||||||
|
|
||||||
|
관리자 블록 에디터의 Shift+Enter는 브라우저 기본 동작에 맡기지 않고 에디터가 줄바꿈 문자를 직접 삽입한 뒤 커서를 줄바꿈 뒤로 복구한다. `contenteditable`의 기본 줄바꿈은 브라우저별로 `<div>`, `<br>`, 텍스트 노드 처리가 달라 Vue 상태 동기화와 충돌할 수 있고, 특히 문단 끝에서 커서가 문단 앞으로 이동하는 현상을 만들 수 있기 때문이다.
|
||||||
|
|
||||||
|
블록 핸들은 문자 아이콘 대신 AFFiNE 참고 스타일의 세로 막대로 표시한다. 작성 중에는 시각 소음을 줄이고, hover 또는 선택 상태에서는 막대가 블록 높이만큼 늘어나 사용자가 선택, 삭제, 드래그할 범위를 바로 인식하게 하기 위해서다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.38
|
||||||
|
|
||||||
|
### 에디터 문단 모델과 설정 패널 액션 배치 결정
|
||||||
|
|
||||||
|
관리자 블록 에디터에서 Shift+Enter는 같은 블록 안의 줄바꿈으로, Enter는 새 문단 블록 생성으로 구분한다. Ghost와 AFFiNE 계열 에디터처럼 한 문단 안의 강제 줄바꿈과 문단 종료가 달라야 블록 선택, 삭제, 이동 범위가 사용자가 인식하는 문단 단위와 맞기 때문이다.
|
||||||
|
|
||||||
|
블록 간격은 각 구조형 블록의 위아래 margin을 섞지 않고 다음 블록의 `margin-top` 기준으로 정리한다. 이렇게 하면 갤러리, 코드, 토글 같은 구조형 블록을 선택했을 때 선택 범위 바깥으로 불필요한 여백이 잡히는 일을 줄이고, 인접 블록 사이 간격도 한 방향에서 관리할 수 있다.
|
||||||
|
|
||||||
|
글 수정 화면의 보기와 삭제 액션은 편집 본문 위에 띄우지 않고 우측 게시물 설정 패널 안에 배치한다. 보기 액션은 Post URL과 직접 관련되므로 Post URL 라벨 오른쪽에 두고, 삭제는 파괴적 액션이므로 설정 패널 하단의 독립 버튼으로 분리한다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.37
|
||||||
|
|
||||||
|
### 블록 에디터 입력 안정성과 블록 핸들 범위 결정
|
||||||
|
|
||||||
|
관리자 블록 에디터의 한글 조합 입력은 compositionend 직후 DOM 텍스트가 완전히 반영된 다음 슬래시 메뉴 필터와 Enter 처리를 갱신한다. 조합 직후 별도 Enter guard로 입력을 막으면 글자가 확정된 뒤에도 사용자가 Enter를 한 번 더 눌러야 하므로, 조합 중 이벤트만 막고 조합 종료 후에는 즉시 일반 입력 흐름으로 돌린다.
|
||||||
|
|
||||||
|
블록 핸들은 1차로 선택, Delete/Backspace 삭제, 드래그 이동까지 제공한다. AFFiNE식 잘라내기, 복사, 서식 툴바, 블록 단위 컨텍스트 메뉴는 작성 경험 전반에 영향을 주는 큰 기능이므로 기본 블록 조작이 안정화된 뒤 별도 단계로 확장한다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.35
|
||||||
|
|
||||||
|
### 관리자 글쓰기 전체 화면 모드 보정 결정
|
||||||
|
|
||||||
|
관리자 글 작성/수정 화면에서는 좌측 관리자 네비게이션과 공통 내부 패딩을 숨기고, 글쓰기 폼이 브라우저 높이를 직접 사용하는 전체 화면 편집 모드로 동작하게 한다. Ghost와 WordPress류 편집 화면은 작성 중 관리자 메뉴보다 글 본문과 설정 패널의 관계가 더 중요하므로, 네비게이션이 보이면 작성 영역을 불필요하게 압축하고 시선을 분산시키기 때문이다.
|
||||||
|
|
||||||
|
글쓰기 화면의 1차 레이아웃은 상단 헤더 전체 폭이 아니라 에디터 작업 영역과 우측 설정 패널의 좌우 분할로 둔다. 설정 패널이 열리거나 닫힐 때 에디터 작업 영역의 상단 도구막대와 본문 폭이 함께 변해야 사용자가 현재 편집 가능한 폭 변화를 자연스럽게 인식할 수 있다.
|
||||||
|
|
||||||
|
## 2026-05-07 v0.0.32
|
||||||
|
|
||||||
|
### 관리자 글 작성 화면 구조 정리 결정
|
||||||
|
|
||||||
|
관리자 글 작성/수정 화면은 별도 페이지 제목 영역을 제거하고, 글쓰기 폼 자체의 상단 도구막대와 본문 중심 레이아웃으로 정리한다. 기존 “새 글 작성” 제목은 현재 작업 맥락을 반복해 화면 높이만 차지했고, 실제 작성 흐름에서는 제목 입력과 본문 시작 위치를 아래로 밀어 Ghost 스타일 편집감과 멀어졌기 때문이다.
|
||||||
|
|
||||||
|
대표 이미지는 설정 패널의 부가 항목이 아니라 글 제목 위의 본문 흐름에서 바로 추가하도록 둔다. 게시물 설정은 420px 우측 패널로 분리하고 토글할 수 있게 해, 설정을 열었을 때는 Figma 설정 패널 상태를 따르고 닫았을 때는 집중형 작성 화면을 유지한다.
|
||||||
|
|
||||||
|
## 2026-05-03 v0.0.31
|
||||||
|
|
||||||
|
### 글 미리보기 저장 방식 결정
|
||||||
|
|
||||||
|
글 미리보기는 데이터베이스에 임시 초안 레코드를 만들지 않고 브라우저 저장소를 통해 현재 작성 폼 값을 전달한다. 저장 전 내용 확인이 목적이므로 DB에 미리보기용 글이 쌓이거나 슬러그 충돌을 만드는 일을 피하기 위해서다.
|
||||||
|
|
||||||
|
미리보기 화면은 `/admin/posts/preview` 관리자 경로에 두고, 공개 게시물 상세와 같은 `ContentRenderer`, `ProseHeaderCard`, `ContentMarkdownRenderer` 조합으로 본문을 렌더링한다. 이렇게 하면 저장 전에도 공개 화면에 가까운 결과를 빠르게 확인할 수 있다.
|
||||||
|
|
||||||
|
## 2026-05-03 v0.0.30
|
||||||
|
|
||||||
|
### OG 이미지 저장 방식 결정
|
||||||
|
|
||||||
|
게시물 OG 이미지는 대표 이미지와 별도 필드인 `og_image`로 저장한다. 대표 이미지는 화면 카드와 본문 진입 시각 요소에 쓰이고, OG 이미지는 외부 공유 미리보기 비율과 목적이 다를 수 있기 때문이다.
|
||||||
|
|
||||||
|
관리자 입력은 대표 이미지와 같은 미디어 선택/업로드 흐름을 재사용한다. OG 이미지가 비어 있으면 공개 상세 화면에서는 대표 이미지를 fallback으로 사용해 기존 글도 기본 공유 이미지를 가질 수 있게 한다.
|
||||||
|
|
||||||
|
## 2026-05-03 v0.0.29
|
||||||
|
|
||||||
|
### 게시물 SEO 설정 범위 결정
|
||||||
|
|
||||||
|
게시물 SEO 설정은 우선 검색 결과에 직접 영향을 주는 SEO 제목, SEO 설명, canonical URL, robots noindex 값만 다룬다. OG 이미지는 대표 이미지 재사용 여부와 별도 이미지 선택 흐름이 더 필요하므로 이번 단계에서는 기본 OG 제목/설명/URL만 공개 상세 화면에 연결하고, 전용 OG 이미지는 다음 작업으로 남긴다.
|
||||||
|
|
||||||
|
SEO 제목과 설명이 비어 있으면 기존 글 제목과 요약을 fallback으로 사용한다. 이렇게 하면 모든 글에 값을 강제로 입력하지 않아도 공개 화면의 기본 메타 품질을 유지할 수 있다.
|
||||||
|
|
||||||
|
## 2026-05-03 v0.0.28
|
||||||
|
|
||||||
|
### 예약 발행 저장 방식 결정
|
||||||
|
|
||||||
|
예약 발행은 별도 `scheduled` 상태를 추가하지 않고 기존 `published` 상태와 미래 `published_at` 값을 조합해 처리한다. 현재 데이터베이스의 게시물 상태 제약은 `published`, `draft`, `private`만 허용하고 있으므로 상태값을 늘리기보다 공개 API의 조회 조건으로 발행 시각을 확인하는 편이 변경 범위가 작다.
|
||||||
|
|
||||||
|
관리자 목록에서는 미래 발행 시각을 가진 `published` 게시물을 예약 상태로 표시한다. 공개 목록과 상세 API는 `published_at`이 비어 있거나 현재 시각 이전인 발행 글만 노출한다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.27
|
||||||
|
|
||||||
|
### 미디어 폴더 트리 관리 방식 결정
|
||||||
|
|
||||||
|
미디어 폴더는 워드프레스 플러그인형 폴더 UX처럼 왼쪽 트리에서 만들고 선택하지만, 실제 업로드 파일 경로는 이동하지 않는다. 이미 게시물과 페이지에 저장된 이미지 URL이 깨지는 일을 막기 위해 폴더 이동은 `media_metadata.category` 값을 경로 문자열로 갱신하는 방식으로 처리한다.
|
||||||
|
|
||||||
|
빈 폴더도 남길 수 있어야 하므로 `media_folders` 테이블을 별도로 둔다. 다만 미디어 사용 여부와 공개 렌더링은 계속 URL 기준으로 판단하며, Ctrl/Command 및 Shift 복수 선택과 드래그 이동은 선택된 URL 목록의 메타데이터만 일괄 변경한다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.26
|
||||||
|
|
||||||
|
### 미디어 카테고리 저장 방식 결정
|
||||||
|
|
||||||
|
미디어 카테고리는 실제 파일 경로나 URL을 변경하지 않고 `media_metadata` 테이블에 URL별 메타데이터로 저장한다. 업로드 파일을 폴더별로 이동하면 이미 게시물이나 페이지에 저장된 이미지 URL이 깨질 수 있기 때문이다.
|
||||||
|
|
||||||
|
파일명 변경은 사용 중인 미디어에서 차단되어 있지만, 미사용 파일명을 변경할 때는 기존 URL의 메타데이터도 새 URL로 옮긴다. 삭제 시에는 남은 메타데이터가 쌓이지 않도록 함께 정리한다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.25
|
||||||
|
|
||||||
|
### 빈 문단 placeholder 표시와 네비게이션 관리 범위 결정
|
||||||
|
|
||||||
|
블록 에디터의 `/` 안내 문구는 첫 빈 화면이거나 마지막 보조 입력 블록일 때만 표시한다. 사용자가 중간에 의도적으로 만든 빈 문단에도 같은 안내가 반복되면 작성 중인 여백이 오류처럼 보이고, 실제 내용보다 placeholder가 더 강하게 눈에 들어오기 때문이다.
|
||||||
|
|
||||||
|
네비게이션 관리는 1차로 공개 왼쪽 사이드바의 상단 메뉴와 하단 링크를 대상으로 한다. 기존 화면에서 이미 해당 영역이 메뉴 역할을 하고 있으므로 새 UI 영역을 만들기보다 하드코딩된 항목을 `navigation_items` 테이블로 옮겨 관리자에서 라벨, URL, 위치, 순서, 표시 여부를 조정할 수 있게 한다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.24
|
||||||
|
|
||||||
|
### 빈 줄 입력 보존과 사이트 설정 범위 결정
|
||||||
|
|
||||||
|
관리자 블록 에디터는 마지막에 클릭 가능한 빈 문단을 유지하지만, 사용자가 Enter로 만든 연속 빈 문단은 자동 삭제하지 않는다. 글 작성 중 여러 줄을 띄워 생각의 구간을 나누는 행동이 자연스럽고, 보조 입력 블록 정리 로직이 사용자의 입력 의도를 지우면 안 되기 때문이다.
|
||||||
|
|
||||||
|
사이트 설정은 우선 단일 `site_settings` 레코드로 관리한다. 개인 블로그 초기 단계에서는 여러 사이트나 다국어 설정보다 사이트 이름, 설명, 기본 URL, 텍스트 로고, 저작권 문구를 안정적으로 저장하고 공개 화면에 반영하는 흐름이 더 중요하다. 이미지 기반 로고와 프로필 이미지는 미디어 사용처 추적과 연결해야 하므로 이후 미디어 설정 확장 단계에서 다룬다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.23
|
||||||
|
|
||||||
|
### 고정 페이지 관리 구조 결정
|
||||||
|
|
||||||
|
고정 페이지 작성과 수정은 게시물과 같은 블록형 에디터를 공유하되, 별도 `AdminPageForm`으로 분리한다. 페이지는 상태, 요약, 태그, 발행일이 없는 정적 콘텐츠이므로 게시물 폼을 그대로 재사용하면 불필요한 필드와 저장 조건이 섞이기 때문이다.
|
||||||
|
|
||||||
|
관리자 경로는 내부 리소스 컬렉션 기준으로 `/admin/pages/:id`를 사용하고, 공개 보기 경로는 기존 고정 페이지 공개 구조인 `/pages/:slug`를 유지한다. 페이지는 목록과 태그 흐름에 노출되지 않는 독립 콘텐츠로 다루기 위해서다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.22
|
||||||
|
|
||||||
|
### 글쓰기 하단 빈 블록과 저장 피드백 보정
|
||||||
|
|
||||||
|
이미지, 갤러리, 임베드 같은 비텍스트 블록이 글의 마지막에 오더라도 작성자가 이어서 글을 쓸 수 있도록 에디터 마지막에는 항상 빈 문단 블록을 유지한다. 이 빈 문단은 작성 편의를 위한 입력 지점이므로 내용이 없으면 저장 마크다운에는 포함하지 않는다.
|
||||||
|
|
||||||
|
한글 조합 입력 직후 Enter는 IME 확정 동작으로 들어오는 경우가 있으므로 즉시 새 블록 생성으로 처리하지 않는다. 조합 확정 Enter와 문단 이동 Enter를 분리해 마지막 글자가 다음 블록에 중복 입력되는 문제를 줄이기 위해서다.
|
||||||
|
|
||||||
|
저장 버튼을 눌렀을 때 동작 여부가 보이지 않으면 작성자가 같은 동작을 반복할 수 있으므로, 저장/수정/삭제 진행과 결과는 우측 상단 토스트로 표시한다. 새 글 저장 후 수정 화면으로 이동하는 경우에도 성공 토스트를 이어서 표시한다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.21
|
||||||
|
|
||||||
|
### 글 작성 중 자동 저장 범위 결정
|
||||||
|
|
||||||
|
글 작성 중 자동 저장은 1차로 브라우저 `localStorage`에 보존한다. 저장 버튼을 누르기 전까지 서버에 게시물을 생성하지 않으면, 의도하지 않은 초안이 DB에 쌓이거나 슬러그 충돌이 발생하는 일을 피할 수 있기 때문이다.
|
||||||
|
|
||||||
|
자동 저장본은 새 글과 기존 글을 서로 다른 키로 분리한다. 작성 화면에 다시 들어왔을 때는 자동으로 덮어쓰지 않고 복원/삭제 선택지를 보여준다. 명시적인 저장이 성공하면 해당 자동 저장본을 삭제해 저장 완료 후 오래된 내용이 다시 나타나지 않도록 한다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.20
|
||||||
|
|
||||||
|
### 콜아웃, 토글, 임베드 블록 저장 방식 결정
|
||||||
|
|
||||||
|
콜아웃, 토글, 임베드는 기존 `content` 마크다운 문자열 안에 `:::callout`, `:::toggle`, `:::embed` fenced block으로 저장한다. 이미지 갤러리와 같은 확장 문법을 사용하면 DB 스키마를 바꾸지 않고도 관리자 작성 화면과 공개 렌더러를 함께 확장할 수 있기 때문이다.
|
||||||
|
|
||||||
|
임베드는 1차로 YouTube URL만 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다. Twitter 등 외부 서비스별 스크립트 임베드는 SSR 안정성과 개인정보/스크립트 로딩 정책을 검토한 뒤 별도 단계에서 확장한다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.19
|
||||||
|
|
||||||
|
### 블록 에디터 조합 입력과 이미지 캡션 표시 보정
|
||||||
|
|
||||||
|
관리자 블록 에디터는 한글처럼 조합 과정이 있는 입력 중에는 마크다운 단축 변환과 슬래시 메뉴 상태 확정을 미룬다. 조합 중인 DOM 텍스트를 Vue 상태로 다시 덮어쓰면 마지막 글자가 중복되거나 입력 순서가 어긋날 수 있기 때문이다.
|
||||||
|
|
||||||
|
이미지 삽입 시 파일명이나 미디어 제목을 자동으로 alt/caption에 채우지 않는다. 파일명은 작성자가 공개 본문에서 보려는 설명이 아니므로 기본 화면에서는 숨기고, 필요한 경우 이미지 hover 또는 focus 상태에서만 alt 입력을 열어 직접 작성하도록 한다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.18
|
||||||
|
|
||||||
|
### 공개 URL 복수형/단수형 기준 결정
|
||||||
|
|
||||||
|
게시물과 태그의 전체 목록은 컬렉션이므로 `/posts`, `/tags` 복수형을 사용한다. 개별 게시물과 특정 태그 상세는 하나의 리소스를 가리키므로 `/post/:slug`, `/tag/:slug` 단수형을 기준 경로로 정한다.
|
||||||
|
|
||||||
|
기존에 사용하던 `/posts/:slug`, `/tags/:slug`는 외부 링크나 기존 이동 흐름이 깨지지 않도록 새 단수형 경로로 리다이렉트한다. 관리자 API와 관리자 화면 경로는 내부 관리 리소스 컬렉션이므로 기존 `/admin/posts/:id`, `/admin/tags/:id`를 유지한다.
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.17
|
||||||
|
|
||||||
|
### 대표 이미지와 미디어 화면 밀도 개선 결정
|
||||||
|
|
||||||
|
대표 이미지는 URL을 직접 입력하지 않고 미디어 라이브러리에서 선택하거나 새로 업로드하는 흐름으로 통일한다. 게시물 작성자가 파일 URL을 다루지 않아도 되고, 이미 업로드된 이미지를 재사용할 수 있어야 하기 때문이다. 대표 이미지가 설정되면 썸네일과 삭제/변경 액션을 바로 보여준다.
|
||||||
|
|
||||||
|
미디어 화면은 수백 개 이상의 파일이 쌓이는 전제를 기준으로 카드형 목록에서 고밀도 썸네일 갤러리로 바꾼다. 파일 경로, 용량, 사용 현황, 파일명 변경, 삭제 같은 상세 정보는 워드프레스처럼 선택한 이미지의 상세 모달에서 확인하고 처리한다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.16
|
||||||
|
|
||||||
|
### 미디어 사용처 표시와 삭제 보호 결정
|
||||||
|
|
||||||
|
미디어 라이브러리에서 파일명 변경과 삭제를 제공하면, 해당 이미지가 글 본문이나 대표 이미지에 사용 중인지 먼저 보여줘야 한다. 현재 콘텐츠는 이미지 URL을 게시물/페이지의 `content`와 `featuredImage`에 직접 저장하므로, 사용 중인 파일을 변경하거나 삭제하면 공개 화면의 이미지가 깨진다.
|
||||||
|
|
||||||
|
따라서 1차 사용처 추적은 게시물과 페이지를 대상으로 본문, 대표 이미지 위치를 표시한다. 사용 중인 미디어의 파일명 변경과 삭제는 차단하고, 미사용 파일만 정리할 수 있도록 한다. 프로필이나 사이트 설정 이미지는 아직 해당 데이터 모델이 없으므로 설정 기능 구현 시 사용처 추적에 추가한다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.15
|
||||||
|
|
||||||
|
### 미디어 라이브러리 1차 범위 결정
|
||||||
|
|
||||||
|
글쓰기 화면에서 이미지를 매번 로컬 업로드만 하는 흐름은 장기적으로 불편하므로, 먼저 업로드된 파일을 다시 선택할 수 있는 미디어 선택 창을 붙인다. 관리자 사이드바에는 미디어 메뉴를 추가하고, 업로드된 이미지 목록, 파일명 변경, 삭제를 1차 기능으로 제공한다.
|
||||||
|
|
||||||
|
미디어 데이터는 아직 별도 DB 테이블을 만들지 않고 `public/uploads` 아래 실제 파일 시스템을 기준으로 읽는다. 카테고리 분류와 이미지 사용처 추적은 파일만으로 안정적으로 관리하기 어렵기 때문에 이후 미디어 메타데이터 테이블을 만들 때 함께 확장한다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.14
|
||||||
|
|
||||||
|
### 이미지와 갤러리 블록 구현 범위 결정
|
||||||
|
|
||||||
|
관리자 글쓰기의 이미지 기능은 기존 `content` 필드를 유지하면서 마크다운 확장 문법으로 저장한다. 단일 이미지는 기본 마크다운 이미지 문법에 `{width=wide}` 같은 너비 옵션만 붙이고, 갤러리는 `:::gallery` fenced block 안에 여러 이미지 행을 넣는다. 이렇게 하면 DB 구조를 바꾸지 않고 공개 렌더러와 관리자 에디터를 함께 확장할 수 있다.
|
||||||
|
|
||||||
|
이번 단계에서는 게시물 작성 중 새 이미지를 업로드하고 글에 삽입하는 흐름을 먼저 구현한다. 워드프레스처럼 이미 업로드된 미디어를 선택하거나 파일명 변경, 개별 삭제, 카테고리 분류를 관리하는 기능은 별도 미디어 라이브러리 메뉴에서 다룬다. 글쓰기 화면이 이후 미디어 라이브러리를 호출할 수 있도록 업로드 API와 저장 URL 기준을 먼저 고정한다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.13
|
||||||
|
|
||||||
|
### 개발 서버 로그 요약 방식 결정
|
||||||
|
|
||||||
|
Nuxt 개발 서버의 기본 로그는 프레임워크 상태와 빌드 이벤트를 자세히 보여주지만, 일상적인 로컬 개발에서는 접속 링크만 빠르게 확인하는 편이 더 효율적이다. 따라서 `npm run dev`는 프로젝트 전용 래퍼 스크립트로 Nuxt dev 서버를 실행하고, 터미널에는 Localhost, Local IP, Admin, Tailwind Viewer 링크를 요약 출력한다.
|
||||||
|
|
||||||
|
오류나 경고에 가까운 로그는 계속 터미널에 남긴다. 개발 서버 실행 자체는 Nuxt CLI를 그대로 사용하되 출력만 정리해, 프레임워크 동작 방식은 바꾸지 않고 로컬 사용성만 개선한다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.12
|
||||||
|
|
||||||
|
### 제목과 본문 입력 흐름 보정
|
||||||
|
|
||||||
|
관리자 글쓰기 화면에서 제목은 별도 데이터 필드로 유지하되, 키보드 흐름은 본문 에디터와 이어지도록 한다. 제목 입력 중 Enter를 누르면 폼 제출이 아니라 본문 첫 블록으로 포커스를 이동해 Ghost류 작성 화면처럼 하나의 글쓰기 흐름으로 느껴지게 한다.
|
||||||
|
|
||||||
|
관리자 에디터 본문 색상은 공개 화면용 `post-prose` 전역 색상 변수를 그대로 따르지 않고 관리자 화면의 `ink` 색상으로 고정한다. 시스템 다크모드 변수와 관리자 흰 배경이 섞이면 실제 입력 텍스트가 placeholder보다 흐리거나 읽기 어려워질 수 있기 때문이다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.11
|
||||||
|
|
||||||
|
### 블록 에디터 키보드 흐름 보정
|
||||||
|
|
||||||
|
빈 문단에서 Enter를 누를 때도 다음 빈 문단 블록을 생성하도록 유지한다. 작성 중 의도적으로 여백을 두거나 다음 입력 위치로 이동하는 행동이 자연스러운 글쓰기 흐름이기 때문이다. 저장 시에는 기존처럼 비어 있는 블록을 마크다운 문자열에 포함하지 않는다.
|
||||||
|
|
||||||
|
슬래시 메뉴는 입력 포커스를 본문 블록에 둔 채 키보드로 선택한다. `/제목 3`처럼 필터링한 뒤 Enter를 누르면 현재 강조된 항목을 적용하고, 위/아래 방향키로 강조 항목을 이동한다. 이렇게 하면 메뉴 항목으로 실제 DOM 포커스를 옮기지 않아도 Ghost류 에디터처럼 연속 입력 흐름을 유지할 수 있다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.10
|
||||||
|
|
||||||
|
### 블록 에디터 입력 안정화 결정
|
||||||
|
|
||||||
|
관리자 블록 에디터는 `contenteditable` 요소 안의 텍스트를 Vue 템플릿 보간으로 직접 렌더링하지 않고 DOM 참조를 통해 동기화한다. Vue의 렌더 패치와 브라우저의 조합 입력이 동시에 같은 텍스트 노드를 수정하면 `/` 입력이나 한글 필터 입력이 중복되는 문제가 생기기 때문이다.
|
||||||
|
|
||||||
|
슬래시 메뉴는 고정적으로 아래에 열지 않고 활성 블록 위치와 화면 높이를 기준으로 위 또는 아래에 표시한다. 글 하단에서 블록을 추가할 때 메뉴가 화면 밖으로 밀리는 문제를 줄이기 위해서다.
|
||||||
|
|
||||||
|
제목은 별도 폼 영역이 아니라 에디터 상단의 큰 제목 입력으로 유지한다. Ghost 작성 화면처럼 제목과 본문이 하나의 흐름으로 보이는 편이 글쓰기 집중도와 결과 화면 예측에 더 가깝기 때문이다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.9
|
||||||
|
|
||||||
|
### 관리자 블록형 글쓰기 방식 결정
|
||||||
|
|
||||||
|
관리자 글 작성은 순수 마크다운 textarea가 아니라 Ghost 스타일에 가까운 블록형 에디터를 기준으로 전환한다. 사용자가 `/` 명령으로 블록을 선택하고, `##` 같은 마크다운 단축 입력을 즉시 제목 블록으로 변환해 작성 화면과 결과 화면의 차이를 줄이기 위해서다.
|
||||||
|
|
||||||
|
다만 현재 데이터베이스와 API의 `content` 필드는 그대로 유지한다. 블록 에디터 내부에서는 문단, 제목, 인용, 목록, 코드, 구분선을 블록으로 다루고 저장 시 마크다운 문자열로 직렬화한다. 이렇게 하면 기존 게시물 저장 구조를 깨지 않으면서도 이후 이미지, 임베드, 콜아웃 같은 Ghost 카드형 블록을 단계적으로 확장할 수 있다.
|
||||||
|
|
||||||
|
공개 게시물과 고정 페이지 본문도 같은 마크다운 렌더러를 사용하도록 연결한다. 작성 화면과 보는 화면을 완전히 동일하게 만드는 것은 이미지 업로드와 전체 콘텐츠 컴포넌트 구현 이후 다시 보정하되, 이번 단계에서는 제목, 목록, 인용, 코드 등 기본 블록의 시각 차이를 먼저 줄인다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.8
|
||||||
|
|
||||||
|
### 관리자 마크다운 미리보기 방식 결정
|
||||||
|
|
||||||
|
관리자 글 편집의 미리보기는 저장 형식을 바꾸지 않고 textarea 입력 위에 작성/미리보기 탭을 추가하는 방식으로 시작한다. 현재 공개 게시물 렌더링이 아직 완전한 마크다운 파서를 사용하지 않기 때문에, 관리자 미리보기는 기본 문법 확인용으로 제한하고 원본 마크다운 문자열을 그대로 저장한다.
|
||||||
|
|
||||||
|
편집 편의 기능은 제목, 굵게, 목록, 인용, 코드 블록 삽입 버튼으로 시작한다. 별도 에디터 패키지는 이미지 업로드와 공개 렌더링 방향이 확정된 뒤 필요성을 다시 판단한다.
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.7
|
||||||
|
|
||||||
|
### 관리자 글 작성/수정 구조 결정
|
||||||
|
|
||||||
|
관리자 글 작성과 수정은 `AdminPostForm` 단일 컴포넌트를 공유한다. 현재 단계에서는 별도 위지윅 편집기를 도입하지 않고 마크다운 textarea 입력을 먼저 저장 가능한 형태로 연결한다. 글 관리의 핵심 흐름인 생성, 수정, 상태 변경을 먼저 검증한 뒤 미리보기, 자동 저장, 이미지 업로드를 분리해 확장하기 위해서다.
|
||||||
|
|
||||||
|
발행/초안/비공개 전환은 별도 publish API가 아니라 게시물 수정 API의 `status` 값으로 처리한다. 초기 관리자에서는 버튼과 API를 늘리기보다 저장 모델을 단순하게 유지하고, 추후 목록에서 빠른 발행 전환이 필요해질 때 별도 액션 API를 추가한다.
|
||||||
|
|
||||||
|
### 관리자 태그 관리 방식 결정
|
||||||
|
|
||||||
|
태그 관리는 목록 화면에서 생성/수정 입력을 인라인으로 열지 않고 생성/수정 전용 페이지로 분리한다. 태그에 표시 순서와 색상 코드가 추가되면서 입력 항목이 늘었고, 목록 행 안에서 수정 폼을 열면 테이블 레이아웃이 흔들리기 때문이다.
|
||||||
|
|
||||||
|
태그 삭제 시 게시물 자체는 삭제하지 않고 `post_tags` 연결만 외래 키 규칙으로 정리한다. 태그는 분류 메타데이터이고 게시물 본문 데이터와 생명주기가 다르기 때문이다.
|
||||||
|
|
||||||
|
태그의 `sort_order`는 공개 화면 카테고리 정렬 기준으로 사용하고, `color`는 태그 옆 색상 바와 이후 태그 배지 배경색에 사용할 수 있도록 `#RRGGBB` 문자열로 저장한다.
|
||||||
|
|
||||||
|
### 초기 관리자 인증 방식 결정
|
||||||
|
|
||||||
|
관리자 기능 1차 구현은 별도 사용자 테이블을 만들지 않고 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수로 시작한다. 개인 블로그/CMS 초기 단계에서는 운영 계정 수가 하나이고, 데이터 모델을 먼저 늘리기보다 글 관리 흐름을 빠르게 검증하는 편이 유지보수에 유리하다.
|
||||||
|
|
||||||
|
로그인 성공 시 `/admin` 경로에만 적용되는 httpOnly 세션 쿠키를 설정한다. 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증해 쿠키 위조를 막고, 운영 단계에서 사용자 테이블이나 더 강한 인증 방식이 필요해지는 시점에 확장한다.
|
||||||
|
|
||||||
|
### 로컬 개발 컨테이너 실행 환경 결정
|
||||||
|
|
||||||
|
새 개발 환경에서 Docker Desktop 없이 터미널 중심으로 PostgreSQL 개발 DB를 실행하기 위해 Homebrew, Docker CLI, Docker Compose, Colima 조합을 사용한다. 이 방식은 Docker daemon을 Colima가 담당하고, 프로젝트는 기존 `docker-compose.yml`을 그대로 활용할 수 있어 NAS Docker 배포 구조와 로컬 개발 구조를 크게 벌리지 않는다.
|
||||||
|
|
||||||
|
로컬 개발 DB는 `.env.development`만 사용하고, Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 넘긴다. 이렇게 하면 Git에 포함되지 않는 로컬 비밀번호를 사용하면서도 운영 기본값인 `.env.production` 기준은 유지할 수 있다.
|
||||||
|
|
||||||
|
개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행한다. Docker entrypoint는 새 볼륨 생성 시에만 SQL을 자동 실행하므로, 이미 생성된 개발 DB에도 반복 적용할 수 있는 별도 실행 명령을 둔다.
|
||||||
|
|
||||||
|
## 2026-04-29 v0.0.6
|
||||||
|
|
||||||
|
### 환경 변수 파일 보안 기준 정리
|
||||||
|
|
||||||
|
`.env.example`은 Git에 포함되는 공유 템플릿이므로 실제 개인 이메일, 관리자 비밀번호, DB 비밀번호를 기록하지 않는다. 공유 파일에는 placeholder만 두고, 실제 값은 Git에서 제외되는 `.env.development`와 `.env.production`에서만 관리한다.
|
||||||
|
|
||||||
|
로컬 개발 환경은 `127.0.0.1:43119`로 개발 DB에 연결하고, NAS Docker 운영 환경은 `sori-studio-db:5432`로 운영 DB에 연결한다. 개발 DB와 운영 DB는 비밀번호도 분리해 한쪽 값이 노출되더라도 다른 환경으로 전파되지 않게 한다.
|
||||||
|
|
||||||
|
이미 원격 저장소에 올라간 비밀번호가 실제 사용 값이었다면 Git 이력에서 지워도 안전하다고 볼 수 없으므로, 해당 값은 폐기하고 새 랜덤 값으로 교체하는 것을 전제로 한다.
|
||||||
|
|
||||||
|
## 2026-04-29 v0.0.5
|
||||||
|
|
||||||
|
### PostgreSQL 기반 데이터 계층 결정
|
||||||
|
|
||||||
|
DB 관리 도구로 CloudBeaver를 고려하고 NAS Docker 배포를 전제로 하기 때문에 초기 데이터베이스는 PostgreSQL로 잡는다. SQLite보다 운영/개발 분리, 외부 관리 도구 연결, 향후 확장에 유리하다.
|
||||||
|
|
||||||
|
Nuxt 서버 API는 바로 DB에 강결합하지 않고 `server/repositories`를 통해 콘텐츠를 조회한다. `DATABASE_URL`이 설정된 환경에서는 PostgreSQL을 사용하고, 설정되지 않은 환경에서는 샘플 데이터를 사용해 화면과 API 개발을 계속할 수 있게 했다.
|
||||||
|
|
||||||
|
Docker Compose에는 앱과 PostgreSQL 서비스를 함께 두되, 실제 운영 비밀번호와 연결 문자열은 `.env.production`에서 관리한다. DB 외부 포트는 기존 사용 포트와 겹치지 않도록 `43119`를 사용한다.
|
||||||
|
|
||||||
|
## 2026-04-29 v0.0.4
|
||||||
|
|
||||||
|
### 메뉴 토글 구현 방식 결정
|
||||||
|
|
||||||
|
원본 Ghost 테마는 Alpine 스타일의 `@click`, `:class`, `:aria-expanded` 바인딩으로 좌측 메뉴 상태를 제어한다. 이 프로젝트는 Nuxt/Vue 기반이므로 Alpine을 추가하지 않고 Vue 상태와 composable로 같은 기능을 구현한다.
|
||||||
|
|
||||||
|
메뉴 상태는 `useMenuState`에서 공유하고, 브라우저 `localStorage`의 `MENU_STATE`에 저장한다. 이렇게 하면 헤더 버튼, 공개 레이아웃, 게시물 레이아웃이 같은 상태를 사용하면서도 별도 프론트엔드 상태 라이브러리나 Alpine 의존성을 추가하지 않아도 된다.
|
||||||
|
|
||||||
|
## 2026-04-29 v0.0.3
|
||||||
|
|
||||||
|
### 공개 화면 테마와 패널 구조 보정
|
||||||
|
|
||||||
|
참고 화면 기준으로 공개 화면은 단순한 흰색 페이지가 아니라 헤더 아래 좌측 사이드바, 중앙 본문, 우측 사이드바가 각각 전체 화면 높이를 차지하는 패널 구조로 정했다. 사이드바 콘텐츠가 적어도 패널 자체는 `calc(100vh - 57px)` 높이를 유지한다.
|
||||||
|
|
||||||
|
색상은 초반부터 라이트/다크 모드 기준을 잡기 위해 CSS 변수로 관리한다. 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리해 이후 디자인 조정 시 Tailwind 클래스 전체를 갈아엎지 않도록 했다.
|
||||||
|
|
||||||
|
## 2026-04-29 v0.0.2
|
||||||
|
|
||||||
|
### Nuxt 통합 백엔드 구조 결정
|
||||||
|
|
||||||
|
초기 세팅은 별도 백엔드 앱을 만들지 않고 Nuxt/Nitro의 `server/api`를 사용한다. 개인 블로그와 CMS를 한 서버에서 배포하면 로컬 개발, NAS 운영, 환경 변수 관리가 단순해진다. DB 연결과 API 라우팅은 Nuxt 서버 영역에서 시작하고, 추후 독립 배포나 워커가 필요해질 때 백엔드 분리를 재검토한다.
|
||||||
|
|
||||||
|
Nuxt 3, Tailwind CSS, Zod를 실제 의존성으로 추가하고 공개 화면, 관리자 화면, 콘텐츠 컴포넌트의 초기 골격을 만들었다. 현재 API는 샘플 데이터 기반이며 다음 단계에서 개발 DB로 교체한다.
|
||||||
|
|
||||||
|
기본 포트와 사용 중인 포트 충돌을 피하기 위해 로컬 개발 서버는 `43117`, NAS Docker 외부 포트는 `43118`을 사용한다. 컨테이너 내부 포트는 Nuxt 기본 실행 흐름에 맞춰 `3000`으로 유지한다.
|
||||||
|
|
||||||
## 2026-04-29 v0.0.1
|
## 2026-04-29 v0.0.1
|
||||||
|
|
||||||
### 초기 제품 방향 결정
|
### 초기 제품 방향 결정
|
||||||
|
|||||||
237
docs/map.md
237
docs/map.md
@@ -6,58 +6,259 @@
|
|||||||
|
|
||||||
| 파일 | 화면 |
|
| 파일 | 화면 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| layouts/default.vue | 메인, 목록, 태그 페이지 |
|
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
|
||||||
| layouts/post.vue | 개별 게시물 |
|
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
||||||
| layouts/admin.vue | 관리자 전체 |
|
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
||||||
| layouts/page.vue | 고정 페이지 전체 화면 |
|
| layouts/page.vue | 고정 페이지 전체 화면 |
|
||||||
|
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template |
|
||||||
|
|
||||||
|
## Composables
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| composables/formatPostDate.js | 공개 화면 게시일 `YYYY.MM.DD` 포맷 |
|
||||||
|
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
|
||||||
|
|
||||||
|
## 공유 라이브러리(서버·클라이언트 공통)
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree` |
|
||||||
|
|
||||||
|
## Nuxt 모듈
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| modules/nuxt-ssr-paths-write.mjs | `paths.mjs`를 `.nuxt`에 기록해 Node가 `#internal/nuxt/paths`를 해석할 수 있게 함 |
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| scripts/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 |
|
||||||
|
|
||||||
|
## 서버 미들웨어
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
|
||||||
|
|
||||||
## 사이트 컴포넌트
|
## 사이트 컴포넌트
|
||||||
|
|
||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단 |
|
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||||
| components/site/LeftSidebar.vue | 메인 화면 왼쪽 |
|
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
|
||||||
| components/site/RightSidebar.vue | 메인 화면 오른쪽 |
|
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||||
| components/site/MainColumn.vue | 메인 화면 중앙 |
|
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
|
||||||
| components/site/PostCard.vue | 목록의 게시물 카드 |
|
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
|
||||||
|
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||||
|
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||||
|
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||||
|
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||||
|
|
||||||
|
## 관리자 컴포넌트
|
||||||
|
|
||||||
|
| 파일 | 화면 위치 |
|
||||||
|
|------|-----------|
|
||||||
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||||
|
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||||
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||||
|
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||||
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||||
|
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
||||||
|
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||||
|
|
||||||
|
## 관리자 컴포저블
|
||||||
|
|
||||||
|
| 파일 | 화면 위치 |
|
||||||
|
|------|-----------|
|
||||||
|
| composables/useAdminUnsavedChangesGuard.js | 관리자 게시글/멤버 편집 화면 라우트 이탈 확인과 브라우저 `beforeunload` 연결 |
|
||||||
|
|
||||||
## 콘텐츠 컴포넌트
|
## 콘텐츠 컴포넌트
|
||||||
|
|
||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||||
|
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 확장 블록 파싱 |
|
||||||
| components/content/ProseHeading.vue | h1~h6 제목 |
|
| components/content/ProseHeading.vue | h1~h6 제목 |
|
||||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||||
| components/content/ProseList.vue | 목록 |
|
| components/content/ProseList.vue | 목록 |
|
||||||
| components/content/ProseBlockquote.vue | 인용구 |
|
| components/content/ProseBlockquote.vue | 인용구 |
|
||||||
| components/content/ProseButton.vue | 버튼 |
|
| components/content/ProseButton.vue | 버튼 |
|
||||||
| components/content/ProseCallout.vue | Callout 카드 |
|
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 배경 프리셋, 상단 여백 중심) |
|
||||||
| components/content/ProseToggle.vue | Toggle 카드 |
|
| components/content/ProseToggle.vue | Toggle 카드 |
|
||||||
| components/content/ProseVideo.vue | 비디오 |
|
| components/content/ProseVideo.vue | 비디오 |
|
||||||
| components/content/ProseAudio.vue | 오디오 |
|
| components/content/ProseAudio.vue | 오디오 |
|
||||||
| components/content/ProseFile.vue | 파일 |
|
| components/content/ProseFile.vue | 파일 |
|
||||||
| components/content/ProseProduct.vue | 상품 카드 |
|
| components/content/ProseProduct.vue | 상품 카드 |
|
||||||
|
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) |
|
||||||
|
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
|
||||||
| components/content/ProseHeaderCard.vue | 헤더 카드 |
|
| components/content/ProseHeaderCard.vue | 헤더 카드 |
|
||||||
| components/content/ProseEmbed.vue | YouTube, Twitter 임베드 |
|
| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 `http(s)` 외부 링크 |
|
||||||
|
|
||||||
## 관리자 페이지
|
## 관리자 페이지
|
||||||
|
|
||||||
| 파일 | 화면 |
|
| 파일 | 화면 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| pages/admin/index.vue | 대시보드 |
|
| pages/admin/index.vue | 대시보드 |
|
||||||
| pages/admin/posts/index.vue | 글 목록 |
|
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||||
| pages/admin/posts/new.vue | 글 작성 |
|
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
||||||
| pages/admin/posts/[id].vue | 글 수정 |
|
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
||||||
|
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
||||||
|
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||||
| pages/admin/tags/index.vue | 태그 관리 |
|
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||||
| pages/admin/settings/index.vue | 사이트 설정 |
|
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||||
|
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||||
|
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
|
||||||
|
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||||
|
| pages/admin/tags/new.vue | 태그 생성 |
|
||||||
|
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||||
|
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·파비콘 생성, 저작권 문구) |
|
||||||
|
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||||
|
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||||
|
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||||
|
|
||||||
## 공개 페이지
|
## 공개 페이지
|
||||||
|
|
||||||
| 파일 | 화면 |
|
| 파일 | 화면 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| pages/index.vue | 홈 |
|
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||||
| pages/posts/[slug].vue | 블로그 글 상세 |
|
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||||
| pages/tags/[slug].vue | 태그별 글 목록 |
|
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||||
|
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||||
|
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||||
|
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||||
|
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||||
|
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
|
||||||
|
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
||||||
|
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
|
||||||
|
| pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
|
||||||
|
|
||||||
|
## 서버 API
|
||||||
|
|
||||||
|
| 파일 | 기능 |
|
||||||
|
|------|------|
|
||||||
|
| server/api/posts.get.js | 게시물 목록 샘플 API |
|
||||||
|
| server/api/posts/[slug].get.js | 게시물 상세 샘플 API |
|
||||||
|
| server/api/pages.get.js | 고정 페이지 목록 샘플 API |
|
||||||
|
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
|
||||||
|
| server/api/tags.get.js | 태그 목록 샘플 API |
|
||||||
|
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
|
||||||
|
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||||
|
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||||
|
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
|
||||||
|
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
|
||||||
|
| server/api/auth/email-otp/request.post.js | Resend로 OTP 발송(`signup` / `password_reset`), 발송 실패 챌린지 삭제 |
|
||||||
|
| server/api/auth/password-reset/confirm.post.js | OTP 검증 후 비밀번호 재설정 |
|
||||||
|
| server/repositories/email-otp-repository.js | `email_otp_challenges` CRUD·검증 |
|
||||||
|
| server/utils/email-otp.js | OTP 생성·해시 |
|
||||||
|
| server/utils/resend-mail.js | Resend REST 발송 |
|
||||||
|
| server/api/auth/login.post.js | 회원 로그인 API |
|
||||||
|
| server/api/auth/me.get.js | 회원 세션 조회 API |
|
||||||
|
| server/api/auth/logout.post.js | 회원 로그아웃 API |
|
||||||
|
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
|
||||||
|
| server/api/auth/profile.put.js | 회원 프로필 수정 API(닉네임·`avatarUrl`; 관리 썸네일 URL 교체 시 메타만 분리) |
|
||||||
|
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API(WebP 변환, 최소 해상도 검증, 중앙 1:1 강제 크롭, 품질 보정, `media_metadata` 논리 폴더 `썸네일`) |
|
||||||
|
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
|
||||||
|
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
|
||||||
|
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
|
||||||
|
| server/api/auth/account.delete.js | 회원 탈퇴 API(마지막 `owner` 보호, 관리자 세션 함께 정리) |
|
||||||
|
| server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API |
|
||||||
|
| server/api/posts/[slug]/comments.post.js | 게시물 댓글 작성 API |
|
||||||
|
| server/api/posts/[slug]/comments/[commentId]/like.post.js | 댓글 좋아요 토글 API |
|
||||||
|
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
|
||||||
|
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
|
||||||
|
| server/routes/admin/api/auth/me.get.js | 관리자 세션 조회 API |
|
||||||
|
| server/routes/admin/api/posts.get.js | 관리자 게시물 목록 API |
|
||||||
|
| server/routes/admin/api/posts.post.js | 관리자 게시물 생성 API |
|
||||||
|
| server/routes/admin/api/posts/[id].get.js | 관리자 게시물 상세 API |
|
||||||
|
| server/routes/admin/api/posts/[id].put.js | 관리자 게시물 수정 API |
|
||||||
|
| server/routes/admin/api/posts/[id].delete.js | 관리자 게시물 삭제 API |
|
||||||
|
| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API |
|
||||||
|
| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API |
|
||||||
|
| server/routes/admin/api/pages/[id].get.js | 관리자 고정 페이지 상세 API |
|
||||||
|
| server/routes/admin/api/pages/[id].put.js | 관리자 고정 페이지 수정 API |
|
||||||
|
| server/routes/admin/api/pages/[id].delete.js | 관리자 고정 페이지 삭제 API |
|
||||||
|
| server/routes/admin/api/media.get.js | 관리자 미디어 목록 API |
|
||||||
|
| server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 및 단일/복수 폴더 변경 API |
|
||||||
|
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
||||||
|
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||||
|
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
|
||||||
|
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||||
|
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
||||||
|
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||||
|
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||||
|
| server/routes/admin/api/tags/[id].put.js | 관리자 태그 수정 API |
|
||||||
|
| server/routes/admin/api/tags/[id].delete.js | 관리자 태그 삭제 API |
|
||||||
|
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
|
||||||
|
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||||
|
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||||
|
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||||
|
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||||
|
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
||||||
|
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
|
||||||
|
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
|
||||||
|
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API |
|
||||||
|
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
||||||
|
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||||
|
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||||
|
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||||
|
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||||
|
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
|
||||||
|
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||||
|
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||||
|
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||||
|
| server/utils/admin-navigation-input.js | 관리자 네비게이션 입력값 검증 스키마 |
|
||||||
|
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
|
||||||
|
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
|
||||||
|
| server/utils/navigation-items.js | DB 없을 때 기본 네비 항목(UUID id·parentId·isFolder) |
|
||||||
|
| server/utils/navigation-tree.js | 네비 검증·삽입 순서·공개 primary 트리·DFS sort_order 재부여 |
|
||||||
|
| server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단 |
|
||||||
|
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||||
|
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||||
|
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||||
|
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||||
|
|
||||||
|
## 데이터베이스
|
||||||
|
|
||||||
|
| 파일 | 기능 |
|
||||||
|
|------|------|
|
||||||
|
| db/migrations/001_initial_schema.sql | PostgreSQL 초기 테이블 스키마 |
|
||||||
|
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
|
||||||
|
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
||||||
|
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
|
||||||
|
| db/migrations/017_navigation_hierarchy.sql | `navigation_items`에 `parent_id`·`is_folder`, `(location,label,url)` 유니크 제거 |
|
||||||
|
| db/migrations/019_dedupe_navigation_items.sql | 반복 마이그레이션으로 생긴 네비게이션 중복 행 정리 및 중복 방지 인덱스 |
|
||||||
|
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
|
||||||
|
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
|
||||||
|
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
|
||||||
|
| db/migrations/016_media_category_normalize.sql | `media_metadata` 레거시 `posts`→`미분류`, `회원/썸네일`→`썸네일` 정리 |
|
||||||
|
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
|
||||||
|
| db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 |
|
||||||
|
| db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 |
|
||||||
|
| db/migrations/011_add_member_profile_and_activity.sql | 회원 아바타/최근 활동 컬럼 추가 및 닉네임 유니크 인덱스 추가 |
|
||||||
|
| db/migrations/012_add_comment_likes.sql | 댓글 좋아요 테이블 추가 |
|
||||||
|
| db/migrations/014_add_user_role_levels.sql | 회원 권한 3단계(owner/admin/member) 컬럼 추가 |
|
||||||
|
| db/migrations/013_add_user_admin_role.sql | 회원 관리자 권한 컬럼 추가 및 첫 사용자 관리자 승격 |
|
||||||
|
| db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 |
|
||||||
|
|
||||||
|
## 설정/배포
|
||||||
|
|
||||||
|
| 파일 | 기능 |
|
||||||
|
|------|------|
|
||||||
|
| package.json | Nuxt 실행 스크립트와 의존성 |
|
||||||
|
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 |
|
||||||
|
| tailwind.config.js | Tailwind 테마 설정 |
|
||||||
|
| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
|
||||||
|
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) |
|
||||||
|
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
|
||||||
|
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
|
||||||
|
| scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 |
|
||||||
|
| scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 |
|
||||||
|
| .env.example | 환경 변수 예시 |
|
||||||
|
| Dockerfile | NAS 운영 이미지 빌드 |
|
||||||
|
| docker-compose.yml | NAS 컨테이너 실행 초안 |
|
||||||
|
|||||||
530
docs/spec.md
530
docs/spec.md
@@ -6,8 +6,9 @@
|
|||||||
- **유형**: 커스텀 블로그/CMS
|
- **유형**: 커스텀 블로그/CMS
|
||||||
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
||||||
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
|
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
|
||||||
- **현재 상태**: 코드 스캐폴딩 전 문서 기준점
|
- **현재 상태**: Nuxt 3.21(SSR)·PostgreSQL 저장소 계층 구성 완료. Node가 SSR 번들의 `#internal/nuxt/paths`를 해석하도록 루트 `package.json` `imports`와 `modules/nuxt-ssr-paths-write.mjs`(`.nuxt/paths.mjs` 디스크 기록)을 둔다.
|
||||||
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
|
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
|
||||||
|
- **스타일**: Tailwind 엔트리는 `assets/css/main.css` 한 곳(`nuxt.config`의 `tailwindcss.cssPath`)이며, `tailwind.config.js`의 `content`가 Vue·composables·modules·plugins를 스캔한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,15 +18,61 @@
|
|||||||
|
|
||||||
| 요소 | 크기/속성 |
|
| 요소 | 크기/속성 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| Header | 높이 57px |
|
| Header | 높이 57px, `sticky top-0`, `shrink-0`. 내부는 `grid-cols-3`로 **좌(브랜드·메뉴) / 중앙(검색, `md+`에서만 표시) / 우(사용자 메뉴)** 배치해 검색 패널을 가운데 열에 정렬한다. 검색 버튼은 중앙 열 안에서 `max-w-[min(470px,100%)]`로 폭을 제한한다. |
|
||||||
| Left Aside | 너비 287px, 패딩 12px 12px 12px 0 |
|
| Shell | `min-height: 100vh`, `flex` 세로 컬럼 |
|
||||||
| Main | 너비 720px, 패딩 32px 24px (헤더), 16px 24px (섹션) |
|
| 그리드(데스크톱 `lg+`) | `items-start`, 3열 그리드(`287px / minmax(0,1fr) / 287px`)를 사용하고 열 간 `column-gap`은 두지 않는다(`gap-x-0`). 경계선은 사이드바 보더로만 구분해 이중 패딩처럼 보이는 여백을 방지한다. 긴 본문은 **문서(`html`/`body`) 스크롤**로 처리한다. |
|
||||||
| Right Aside | 너비 287px, 패딩 20px 0 20px 20px |
|
| 그리드(모바일 `lg` 미만) | 단일 세로 흐름: **본문 → 오른쪽 사이드** 순. 왼쪽 사이드는 레이아웃 흐름에서 분리된 고정 슬라이드 패널로 표시 |
|
||||||
|
| Left Aside | 너비 287px, `sticky top-[57px]`, `h-[calc(100vh-57px)]`와 `max-h` 동일(뷰포트 기준 고정 높이), 내부 상단은 `.site-sidebar-scroll`(스크롤바 숨김), 하단 푸터 `shrink-0`·상단 보더로 스크롤 영역과 구분, 푸터 좌우는 `px-4`~`sm:px-5`로 본문 블록과 유사한 여백. 푸터의 API `footer` 링크 영역은 `flex-wrap`·`min-w-0 flex-1`로 항목이 많을 때 줄바꿈되어 패널 밖으로 넘치지 않는다 |
|
||||||
|
| 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-*`는 두지 않는다. |
|
||||||
|
| Right Aside | Left와 동일 패턴(스티키·고정 높이·내부 무스크롤바 스크롤·하단 카피라이트) |
|
||||||
|
| Right Aside(모바일) | 본문 아래에 전체 너비로 배치, 상단 보더로 본문과 구분, 좌우 안전 여백(`px-4`) 적용 |
|
||||||
|
|
||||||
|
### 메뉴 토글
|
||||||
|
|
||||||
|
- 헤더 좌측 아이콘은 브랜드 마크가 아니라 왼쪽 사이드바 열기/닫기 버튼
|
||||||
|
- 메뉴 상태는 Nuxt/Vue 상태로 관리
|
||||||
|
- 브라우저에서는 `localStorage.MENU_STATE`에 `open` 또는 `closed` 저장
|
||||||
|
- 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용
|
||||||
|
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
|
||||||
|
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
||||||
|
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
|
||||||
|
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Anonymous·Sign up·Sign in 메뉴를 표시한다.
|
||||||
|
|
||||||
|
### 공개 화면 색상
|
||||||
|
|
||||||
|
- 라이트/다크 모드는 CSS 변수로 관리
|
||||||
|
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
|
||||||
|
- 라이트 모드 기본 배경은 `#fcfcfc`로 통일하고 패널 구분은 보더로 처리
|
||||||
|
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
|
||||||
|
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리
|
||||||
|
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||||
|
|
||||||
|
### 홈 Featured (인덱스)
|
||||||
|
|
||||||
|
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
|
||||||
|
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
|
||||||
|
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
|
||||||
|
|
||||||
### Post 페이지
|
### Post 페이지
|
||||||
|
|
||||||
- Main 좌우 패딩: 24px → 20px
|
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
||||||
|
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||||
|
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
|
||||||
|
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||||
|
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
|
||||||
|
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
|
||||||
|
- 댓글 정렬은 `Best`(좋아요 우선), `Latest`, `Oldest`를 제공한다.
|
||||||
|
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
|
||||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||||
|
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||||
|
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||||
|
|
||||||
|
### 공개 목록·상세의 발행일 표시
|
||||||
|
|
||||||
|
- API의 ISO 8601 `publishedAt`를 공개 UI에서는 로컬 날짜 기준 `YYYY.MM.DD`로 표시한다.
|
||||||
|
- 변환은 `composables/formatPostDate.js`의 `formatPostDate`를 사용한다.
|
||||||
|
- `<time>`에는 표시용 문자열과 함께 가능한 경우 원본 시각을 `datetime` 속성으로 둔다.
|
||||||
|
|
||||||
### Page 페이지
|
### Page 페이지
|
||||||
|
|
||||||
@@ -34,6 +81,29 @@
|
|||||||
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
|
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
|
||||||
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
|
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
|
||||||
|
|
||||||
|
### 공개 URL 구조
|
||||||
|
|
||||||
|
- `/posts` - 게시물 전체 목록
|
||||||
|
- `/post/:slug` - 개별 게시물 상세
|
||||||
|
- `/tags` - 태그 전체 목록
|
||||||
|
- `/tag/:slug` - 태그별 게시물 목록
|
||||||
|
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
|
||||||
|
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
||||||
|
- `/signin` - 로그인
|
||||||
|
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
|
||||||
|
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
||||||
|
|
||||||
|
### 공개 인증 화면(초기)
|
||||||
|
|
||||||
|
- 회원가입 화면은 AFFiNE 참고 다크 테마 3단계 플로우를 제공한다.
|
||||||
|
- 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다.
|
||||||
|
- 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다.
|
||||||
|
- 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다.
|
||||||
|
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
|
||||||
|
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
|
||||||
|
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
|
||||||
|
- 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
|
||||||
|
|
||||||
### 레이아웃 파일
|
### 레이아웃 파일
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -43,6 +113,17 @@ layouts/
|
|||||||
└── admin.vue # 관리자 화면
|
└── admin.vue # 관리자 화면
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 관리자 레이아웃
|
||||||
|
|
||||||
|
- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
|
||||||
|
- 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
|
||||||
|
- 관리자 우측 캔버스는 기본 `min-h-screen`과 `bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
|
||||||
|
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
|
||||||
|
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||||
|
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||||
|
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||||
|
- 로그아웃은 사이드바 상단 메뉴가 아니라 하단 사용자 썸네일 드롭다운 안에서 제공한다. 하단에는 사용자 썸네일 트리거와 설정 아이콘을 둔다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 컴포넌트 구조
|
## 컴포넌트 구조
|
||||||
@@ -52,6 +133,7 @@ layouts/
|
|||||||
```
|
```
|
||||||
components/site/
|
components/site/
|
||||||
├── SiteHeader.vue # 상단 헤더
|
├── SiteHeader.vue # 상단 헤더
|
||||||
|
├── SiteSearchModal.vue # 통합 검색 모달(`/`·헤더 검색 영역, Tags·Posts 결과)
|
||||||
├── LeftSidebar.vue # 왼쪽 사이드바
|
├── LeftSidebar.vue # 왼쪽 사이드바
|
||||||
├── RightSidebar.vue # 오른쪽 사이드바
|
├── RightSidebar.vue # 오른쪽 사이드바
|
||||||
├── MainColumn.vue # 메인 컬럼
|
├── MainColumn.vue # 메인 컬럼
|
||||||
@@ -75,19 +157,48 @@ components/content/
|
|||||||
├── ProseAudio.vue # Audio 카드
|
├── ProseAudio.vue # Audio 카드
|
||||||
├── ProseFile.vue # File 카드
|
├── ProseFile.vue # File 카드
|
||||||
├── ProseProduct.vue # Product 카드
|
├── ProseProduct.vue # Product 카드
|
||||||
|
├── ProseBookmark.vue # 북마크 카드(썸네일·제목·도메인)
|
||||||
|
├── ProseSignup.vue # 회원가입/뉴스레터 CTA 카드
|
||||||
├── ProseHeaderCard.vue # Header 카드 (Simple/Wide/Full-width/Split)
|
├── ProseHeaderCard.vue # Header 카드 (Simple/Wide/Full-width/Split)
|
||||||
└── ProseEmbed.vue # Embeds (YouTube, Twitter)
|
└── ProseEmbed.vue # Embeds (YouTube iframe, Twitter/X iframe, 기타 링크)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 공개 본문 스타일 가이드(Thred 기준)
|
||||||
|
|
||||||
|
- 리스트
|
||||||
|
- Unordered: `- 항목`
|
||||||
|
- Ordered: `1. 항목`
|
||||||
|
- 렌더링: `ProseList.vue` (마커 컬러, 간격, 줄높이 통일)
|
||||||
|
- 인용구
|
||||||
|
- 기본: `> 한 줄` 또는 `>` 연속 여러 줄(멀티라인)
|
||||||
|
- 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록
|
||||||
|
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`)
|
||||||
|
- 이미지
|
||||||
|
- 기본: ``
|
||||||
|
- 와이드/풀: `{width=wide|full}`
|
||||||
|
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경)
|
||||||
|
- 이미지 갤러리
|
||||||
|
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||||
|
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
|
||||||
|
- 카드류
|
||||||
|
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
||||||
|
- Toggle: `:::toggle 제목` ~ `:::`
|
||||||
|
- Bookmark: `:::bookmark` ~ `:::` (본문은 `url=`, `title=`, `description=`, `thumbnail=` 키값 또는 첫 줄 URL·이어지는 제목/설명 줄)
|
||||||
|
- Signup: `:::signup` ~ `:::` (선택: `title=`, `description=`, `button=`, `placeholder=`)
|
||||||
|
- Embed: `:::embed` ~ `:::` (YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드)
|
||||||
|
- 렌더링: `ProseCallout.vue`, `ProseToggle.vue`, `ProseBookmark.vue`, `ProseSignup.vue`, `ProseEmbed.vue`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 데이터베이스 구조
|
## 데이터베이스 구조
|
||||||
|
|
||||||
### 환경 분리 원칙
|
### 환경 분리 원칙
|
||||||
|
|
||||||
|
- 데이터베이스는 PostgreSQL을 기준으로 한다.
|
||||||
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
||||||
- 로컬 개발 서버는 개발 DB만 연결
|
- 로컬 개발 서버는 개발 DB만 연결
|
||||||
- NAS 배포 환경은 운영 DB만 연결
|
- NAS 배포 환경은 운영 DB만 연결
|
||||||
|
- 운영 환경(`NODE_ENV=production`)에서는 `DATABASE_URL` 누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패
|
||||||
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
||||||
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
||||||
|
|
||||||
@@ -100,12 +211,54 @@ components/content/
|
|||||||
| slug | String | URL 슬러그 |
|
| slug | String | URL 슬러그 |
|
||||||
| content | Text | 마크다운 콘텐츠 |
|
| content | Text | 마크다운 콘텐츠 |
|
||||||
| excerpt | String | 요약 |
|
| excerpt | String | 요약 |
|
||||||
| featured_image | String | 대표 이미지 |
|
| featured_image | String nullable | 대표 이미지 |
|
||||||
|
| seo_title | String | SEO 제목 |
|
||||||
|
| seo_description | String | SEO 설명 |
|
||||||
|
| canonical_url | String | canonical URL |
|
||||||
|
| noindex | Boolean | 검색엔진 노출 제외 여부 |
|
||||||
|
| og_image | String nullable | OG 이미지 |
|
||||||
| status | Enum | published/draft/private |
|
| status | Enum | published/draft/private |
|
||||||
| published_at | DateTime | 발행일 |
|
| published_at | DateTime | 발행일 |
|
||||||
| created_at | DateTime | 생성일 |
|
| created_at | DateTime | 생성일 |
|
||||||
| updated_at | DateTime | 수정일 |
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | UUID | Primary Key |
|
||||||
|
| username | String | 사용자명 |
|
||||||
|
| email | String | 로그인 이메일(유니크) |
|
||||||
|
| password_hash | String | bcrypt 해시 비밀번호 |
|
||||||
|
| avatar_url | String | 프로필 썸네일 URL |
|
||||||
|
| is_admin | Boolean | 관리자 권한 여부 |
|
||||||
|
| user_role | Enum | 권한 단계(`owner`/`admin`/`member`) |
|
||||||
|
| last_seen_at | DateTime nullable | 마지막 접속 시각 |
|
||||||
|
| last_seen_ip | String | 마지막 접속 IP |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | UUID | Primary Key |
|
||||||
|
| post_id | UUID | FK → Posts |
|
||||||
|
| user_id | UUID | FK → Users |
|
||||||
|
| parent_id | UUID nullable | FK → Comments, 대댓글 1단 |
|
||||||
|
| body | Text | 댓글 본문 |
|
||||||
|
| status | Enum | published/pending/blocked |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
|
### CommentLikes
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| comment_id | UUID | FK → Comments |
|
||||||
|
| user_id | UUID | FK → Users |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
|
||||||
### Pages (고정 페이지)
|
### Pages (고정 페이지)
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
| 필드 | 타입 | 설명 |
|
||||||
@@ -114,7 +267,7 @@ components/content/
|
|||||||
| title | String | 제목 |
|
| title | String | 제목 |
|
||||||
| slug | String | URL 슬러그 |
|
| slug | String | URL 슬러그 |
|
||||||
| content | Text | 마크다운 콘텐츠 |
|
| content | Text | 마크다운 콘텐츠 |
|
||||||
| featured_image | String | 대표 이미지 |
|
| featured_image | String nullable | 대표 이미지 |
|
||||||
| created_at | DateTime | 생성일 |
|
| created_at | DateTime | 생성일 |
|
||||||
| updated_at | DateTime | 수정일 |
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
@@ -126,6 +279,55 @@ components/content/
|
|||||||
| name | String | 태그명 |
|
| name | String | 태그명 |
|
||||||
| slug | String | URL 슬러그 |
|
| slug | String | URL 슬러그 |
|
||||||
| description | String | 설명 |
|
| description | String | 설명 |
|
||||||
|
| sort_order | Integer | 메인 태그 정렬 순서 |
|
||||||
|
| color | String | 태그 색상 코드 |
|
||||||
|
| tag_type | Enum | 태그 유형(`managed`/`general`) |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
|
### SiteSettings
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | Integer | 단일 설정 레코드 ID, 항상 1 |
|
||||||
|
| title | String | 사이트 이름 |
|
||||||
|
| description | String | 사이트 설명 |
|
||||||
|
| site_url | String | 사이트 기본 URL |
|
||||||
|
| logo_text | String | 레거시 텍스트 로고 fallback |
|
||||||
|
| logo_url | String | 공개 로고 이미지 URL |
|
||||||
|
| favicon_url | String | 파비콘 이미지 URL |
|
||||||
|
| copyright_text | String | 저작권 문구 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
|
### NavigationItems
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | UUID | Primary Key |
|
||||||
|
| label | String | 메뉴 표시 이름 |
|
||||||
|
| url | String | 내부 경로 또는 외부 URL |
|
||||||
|
| location | Enum | primary/footer |
|
||||||
|
| sort_order | Integer | 표시 순서 |
|
||||||
|
| is_visible | Boolean | 공개 화면 표시 여부 |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
|
### MediaMetadata
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| url | String | 업로드 미디어 URL |
|
||||||
|
| category | String | 논리 폴더 경로(게시물 업로드 이미지는 `미분류`, 회원 아바타는 예약값 `썸네일` 등) |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
|
### MediaFolders
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| path | String | 미디어 폴더 경로 |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
### PostTags (다대다)
|
### PostTags (다대다)
|
||||||
|
|
||||||
@@ -133,32 +335,264 @@ components/content/
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| post_id | UUID | FK → Posts |
|
| post_id | UUID | FK → Posts |
|
||||||
| tag_id | UUID | FK → Tags |
|
| tag_id | UUID | FK → Tags |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API 구조
|
## API 구조
|
||||||
|
|
||||||
> 아직 구현 전 설계안이다. 실제 구현 시 응답 구조와 엔드포인트가 바뀌면 이 문서를 먼저 갱신한다.
|
> 현재 API는 Nuxt `server/api` 내부에 샘플 데이터 기반으로 구현되어 있다. DB 연결 후 같은 응답 구조를 유지하되 저장소만 교체한다.
|
||||||
|
|
||||||
|
### 백엔드 구성
|
||||||
|
|
||||||
|
- 별도 `backend/` 앱을 두지 않고 Nuxt/Nitro 서버 기능을 사용
|
||||||
|
- 공개 API는 `server/api`에 작성
|
||||||
|
- 서버 공통 스키마와 샘플 데이터는 `server/utils`에 작성
|
||||||
|
- PostgreSQL 연결과 조회 로직은 `server/repositories`에 작성
|
||||||
|
- `DATABASE_URL`이 없으면 샘플 데이터 저장소를 사용
|
||||||
|
- 초기 단계에서는 같은 앱 배포로 관리 비용을 낮춤
|
||||||
|
- 독립 API 서버가 필요해지는 시점에만 백엔드 분리를 재검토
|
||||||
|
|
||||||
### 공개 API (`/api/`)
|
### 공개 API (`/api/`)
|
||||||
|
|
||||||
- `GET /api/posts` - 게시물 목록
|
- `GET /api/posts` - 게시물 목록
|
||||||
- `GET /api/posts/:slug` - 게시물 상세
|
- `GET /api/posts/:slug` - 게시물 상세
|
||||||
|
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
|
||||||
|
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
|
||||||
|
- `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요)
|
||||||
- `GET /api/pages` - 고정 페이지 목록
|
- `GET /api/pages` - 고정 페이지 목록
|
||||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||||
- `GET /api/tags` - 태그 목록
|
- `GET /api/tags` - 태그 목록
|
||||||
|
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||||
|
- `GET /api/site-settings` - 공개 사이트 설정
|
||||||
|
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절)
|
||||||
|
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status`의 `emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
|
||||||
|
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
|
||||||
|
- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. 실제 메일 발송이 실패하면 방금 생성한 챌린지는 즉시 삭제한다. 발송 성공 후 같은 이메일·용도의 이전 pending 챌린지는 무효화한다. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요.
|
||||||
|
- `POST /api/auth/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.
|
||||||
|
- `POST /api/auth/login` - 회원 로그인
|
||||||
|
- `GET /api/auth/me` - 현재 회원 세션 조회
|
||||||
|
- `POST /api/auth/logout` - 회원 로그아웃
|
||||||
|
- `GET /api/auth/profile` - 회원 설정 조회
|
||||||
|
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, `avatarUrl`). 이전 값이 `/uploads/members/avatars/` URL이고 새 값과 달라지면 `removeManagedAvatarAsset`으로 **메타만** 끊고 디스크 파일은 유지한다(`DELETE /api/auth/avatar`와 동일한 자산 정리 규칙).
|
||||||
|
- `POST /api/auth/avatar` - 회원 썸네일 이미지 업로드
|
||||||
|
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
|
||||||
|
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
||||||
|
- `PUT /api/auth/password` - 회원 비밀번호 변경
|
||||||
|
- `DELETE /api/auth/account` - 회원 탈퇴. 마지막 `owner` 계정은 삭제할 수 없으며, 탈퇴 성공 시 회원 세션과 관리자 세션 쿠키를 함께 정리한다.
|
||||||
|
|
||||||
|
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MIN_WIDTH`/`AVATAR_MIN_HEIGHT` 최소 해상도 검증 후 중앙 기준 1:1 정사각형으로 크롭한다.
|
||||||
|
> 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다.
|
||||||
|
> `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다.
|
||||||
|
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
|
||||||
|
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
|
||||||
|
|
||||||
### 관리자 API (`/admin/api/`)
|
### 관리자 API (`/admin/api/`)
|
||||||
|
|
||||||
- `POST /admin/api/auth/login` - 로그인
|
- `POST /admin/api/auth/login` - 로그인
|
||||||
|
- `POST /admin/api/auth/logout` - 로그아웃
|
||||||
|
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
|
||||||
- `GET /admin/api/posts` - 글 목록
|
- `GET /admin/api/posts` - 글 목록
|
||||||
- `POST /admin/api/posts` - 글 작성
|
- `POST /admin/api/posts` - 글 작성
|
||||||
|
- `GET /admin/api/posts/:id` - 글 상세
|
||||||
- `PUT /admin/api/posts/:id` - 글 수정
|
- `PUT /admin/api/posts/:id` - 글 수정
|
||||||
- `DELETE /admin/api/posts/:id` - 글 삭제
|
- `DELETE /admin/api/posts/:id` - 글 삭제
|
||||||
- `POST /admin/api/posts/:id/publish` - 글 발행
|
- `GET /admin/api/pages` - 고정 페이지 목록
|
||||||
- `GET /admin/api/tags` - 태그 목록
|
- `POST /admin/api/pages` - 고정 페이지 작성
|
||||||
|
- `GET /admin/api/pages/:id` - 고정 페이지 상세
|
||||||
|
- `PUT /admin/api/pages/:id` - 고정 페이지 수정
|
||||||
|
- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제
|
||||||
|
- `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음)
|
||||||
|
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부; 썸네일 파일명 변경은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
|
||||||
|
- `DELETE /admin/api/media` - 업로드 미디어 삭제(게시물·페이지에서 사용 중이면 거부; `/members/avatars/` URL은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
|
||||||
|
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||||
|
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||||
|
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
||||||
|
- `POST /admin/api/uploads` - 관리자 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링)
|
||||||
|
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||||
- `POST /admin/api/tags` - 태그 생성
|
- `POST /admin/api/tags` - 태그 생성
|
||||||
|
- `GET /admin/api/tags/:id` - 태그 상세
|
||||||
|
- `PUT /admin/api/tags/:id` - 태그 수정
|
||||||
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
||||||
|
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
|
||||||
|
- `GET /admin/api/settings` - 사이트 설정 조회
|
||||||
|
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||||
|
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||||
|
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||||
|
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||||
|
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
||||||
|
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
||||||
|
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`
|
||||||
|
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||||
|
|
||||||
|
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||||
|
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||||
|
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||||
|
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||||
|
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||||
|
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||||
|
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다.
|
||||||
|
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||||
|
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
|
||||||
|
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||||
|
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||||
|
|
||||||
|
### 관리자 글 편집
|
||||||
|
|
||||||
|
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
|
||||||
|
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
|
||||||
|
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
||||||
|
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||||
|
- `/` 명령 메뉴가 열린 블록 행은 아래 블록보다 위 stacking 순서로 표시해 메뉴와 본문 텍스트가 겹쳐 보이지 않게 한다.
|
||||||
|
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||||
|
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||||
|
- `/` 명령 메뉴의 검색어가 바뀌지 않은 경우에는 현재 강조 인덱스를 유지해 연속 방향키 이동이 가능해야 한다.
|
||||||
|
- `/` 명령 메뉴 필터는 한글 조합 입력 완료와 방향키/Enter 입력 직전에 현재 DOM 텍스트를 기준으로 동기화한다.
|
||||||
|
- 슬래시 메뉴 방향키 이동 로직은 현재 블록 텍스트가 `/`로 시작할 때만 동작한다.
|
||||||
|
- 슬래시 메뉴는 화면 높이에 맞춰 최대 높이를 제한하고, 넘치는 항목은 내부 스크롤로 표시한다.
|
||||||
|
- 슬래시 메뉴 방향키 이동 시 현재 선택 항목이 스크롤 영역 안에 유지되도록 자동 스크롤한다.
|
||||||
|
- 일반 본문 블록에서는 위/아래 방향키 입력 시 커서가 블록 시작/끝에 도달하면 인접 블록으로 커서를 이동한다.
|
||||||
|
- 관리자 에디터에서 의도적으로 만든 빈 문단은 `<!--sori:blank-paragraph-->` 마커로 저장해 저장/재진입 후에도 유지한다.
|
||||||
|
- 공개 본문 렌더러는 빈 문단 마커를 빈 문단 블록으로 파싱해 문단 간 추가 여백 의도를 유지한다.
|
||||||
|
- `/갤`처럼 필터 결과가 하나로 좁혀진 상태에서 Enter를 누르면 해당 블록 명령을 적용한다.
|
||||||
|
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||||
|
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
|
||||||
|
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
||||||
|
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
|
||||||
|
- 한글 등 조합형 입력 중 Shift+Enter가 들어오면 조합 완료 직후 줄바꿈을 예약 적용한다.
|
||||||
|
- Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
|
||||||
|
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||||
|
- 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다.
|
||||||
|
- 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
|
||||||
|
- 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
|
||||||
|
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
|
||||||
|
- 블록 드래그 중에는 현재 포인터 위치 기준으로 대상 블록 위 또는 아래에 삽입선을 표시하고, 드롭 또는 드래그 종료 시 표시 위치와 같은 곳으로 이동한다.
|
||||||
|
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||||
|
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
||||||
|
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||||
|
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
||||||
|
- 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
|
||||||
|
- 글 작성/수정 화면의 저장 버튼은 현재 입력값이 마지막 저장 기준점과 다를 때만 활성화한다.
|
||||||
|
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
|
||||||
|
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
|
||||||
|
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
|
||||||
|
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
|
||||||
|
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
|
||||||
|
- 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
|
||||||
|
- 글 삭제 액션은 게시물 설정 패널 하단에 기본 중립 톤 버튼으로 제공하고 hover 시 위험 색상으로 강조한다.
|
||||||
|
- 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
|
||||||
|
- 대표 이미지가 설정된 상태의 변경/삭제 액션은 실제 이미지 레이아웃을 바꾸지 않도록 이미지 hover 또는 focus 오버레이로 표시한다.
|
||||||
|
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
|
||||||
|
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
||||||
|
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||||
|
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
||||||
|
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
||||||
|
- 자동 저장본이 있으면 상단 툴바의 상태 문구 옆에서 복원 또는 무시(로컬 초안 삭제)를 선택할 수 있다.
|
||||||
|
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||||
|
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
||||||
|
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
||||||
|
- 미리보기 본문·헤로 영역은 공개 상세와 동일하게 중앙 `max-w-[720px]` 컬럼과 `px-4 sm:px-5` 수평 패딩을 적용한다.
|
||||||
|
- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
|
||||||
|
- 발행 상태에서 발행 시각을 미래로 지정하면 예약 발행으로 저장한다.
|
||||||
|
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
|
||||||
|
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||||
|
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||||
|
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
|
||||||
|
- 태그 배지 삭제 버튼은 SVG 닫기 아이콘으로 표시한다.
|
||||||
|
- 태그 토큰은 게시물 URL용 `toSlug`(한글 로마자화)와 분리하여 한글을 유지하고, 공백은 하이픈으로만 정리하며 `a-z0-9가-힣` 및 하이픈만 허용한다.
|
||||||
|
- 제목 입력에서 한글 IME 조합 중 Enter는 조합 확정으로만 처리하고 본문 에디터 포커스 이동을 실행하지 않는다.
|
||||||
|
- 관리자 게시글 목록의 태그는 쉼표 구분 문자열이 아니라 읽기 전용 배지 목록으로 표시한다.
|
||||||
|
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
|
||||||
|
- 대표 이미지 선택 모달에서 미디어를 클릭하면 선택 상태만 표시하고, 하단 대표 이미지로 적용 버튼을 눌렀을 때 글 폼에 반영한다.
|
||||||
|
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 hover 오버레이 삭제/변경 액션을 표시한다.
|
||||||
|
- 글 SEO 메타(`seo_title`, `seo_description`)는 별도 입력 없이 저장 시 글 제목·요약과 동일하게 기록한다.
|
||||||
|
- 관리자 폼에서는 검색엔진 노출 제외(`noindex`)만 설정할 수 있다.
|
||||||
|
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
||||||
|
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||||
|
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||||
|
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||||
|
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||||
|
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
|
||||||
|
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
||||||
|
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||||
|
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||||
|
- 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
|
||||||
|
- 관리자 갤러리 블록은 이미지 1개일 때 1열, 2개일 때 2열, 3개 이상일 때 3열로 표시한다.
|
||||||
|
- 관리자 갤러리 블록의 이미지 순서는 드래그 앤 드롭으로 변경하며, 드래그 중 삽입 위치를 이미지 사이 라인으로 표시한다.
|
||||||
|
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
||||||
|
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
||||||
|
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
|
||||||
|
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`를 저장할 수 있다. 예: `:::callout emoji=💡 bg=blue`
|
||||||
|
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
|
||||||
|
- 콜아웃 배경 프리셋은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`를 지원한다.
|
||||||
|
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
|
||||||
|
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
|
||||||
|
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
|
||||||
|
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다.
|
||||||
|
- 그 외 URL은 외부 링크 텍스트 카드로 표시한다.
|
||||||
|
- 북마크 블록은 `:::bookmark` fenced block으로 저장할 수 있으며 공개 화면에서 Thred형 가로 카드로 렌더링한다.
|
||||||
|
- 회원가입(뉴스레터) CTA는 `:::signup` fenced block으로 저장할 수 있으며 실제 폼 연동은 후속 작업으로 분리한다.
|
||||||
|
|
||||||
|
### 관리자 페이지 편집
|
||||||
|
|
||||||
|
- 고정 페이지 작성/수정 화면은 게시물과 같은 블록형 에디터를 사용한다.
|
||||||
|
- 고정 페이지는 제목, 슬러그, 본문, 대표 이미지만 저장한다.
|
||||||
|
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
|
||||||
|
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.
|
||||||
|
|
||||||
|
### 사이트 설정
|
||||||
|
|
||||||
|
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||||
|
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||||
|
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 함께 생성한다.
|
||||||
|
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||||
|
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||||
|
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||||
|
|
||||||
|
### 메뉴/네비게이션
|
||||||
|
|
||||||
|
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||||
|
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
|
||||||
|
- 동일 `location`·`parent_id`·`label`·`url` 조합은 중복 저장하지 않는다. 반복 마이그레이션으로 생긴 중복은 `019_dedupe_navigation_items.sql`이 정리하고, 표현식 유니크 인덱스로 재발을 막는다.
|
||||||
|
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**(레거시·중복 행으로 인한 사이드바 이중 표시 방지). `footer`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
|
||||||
|
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
||||||
|
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||||
|
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
|
||||||
|
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
|
||||||
|
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-panel-hover` 배경이 가로 전체를 쓴다.
|
||||||
|
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||||
|
|
||||||
|
### 관리자 인증
|
||||||
|
|
||||||
|
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||||
|
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||||
|
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||||
|
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||||
|
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
|
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
|
||||||
|
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
|
||||||
|
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다.
|
||||||
|
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
|
||||||
|
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
||||||
|
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
|
||||||
|
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
||||||
|
- 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다.
|
||||||
|
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
|
||||||
|
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.
|
||||||
|
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
||||||
|
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
||||||
|
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
||||||
|
|
||||||
|
### 회원 인증
|
||||||
|
|
||||||
|
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||||
|
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
|
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
||||||
|
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `GET/PUT /api/auth/profile`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
|
||||||
|
- 회원 로그인 성공 시 `previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다.
|
||||||
|
- 사용자 설정 화면은 공개 본문 폭에 맞춰 프로필 요약을 상단에 두고, 프로필 입력과 활동 정보를 하단에 배치한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다.
|
||||||
|
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
||||||
|
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`만 사용하며, 값이 없으면 서버 오류로 실패한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -169,10 +603,34 @@ components/content/
|
|||||||
```
|
```
|
||||||
/uploads/posts/YYYY/MM/filename.webp
|
/uploads/posts/YYYY/MM/filename.webp
|
||||||
/uploads/pages/YYYY/MM/filename.webp
|
/uploads/pages/YYYY/MM/filename.webp
|
||||||
|
/uploads/members/avatars/YYYY/MM/filename.webp
|
||||||
/uploads/system/logo.png
|
/uploads/system/logo.png
|
||||||
/uploads/system/favicon.png
|
/uploads/system/favicon.png
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
|
||||||
|
- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다.
|
||||||
|
- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시 `-2` 넘버링)이다. 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다.
|
||||||
|
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
|
||||||
|
|
||||||
|
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||||
|
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
||||||
|
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||||
|
- 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **썸네일** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 썸네일 탭에서는 `/members/avatars/` 파일만 검색·탐색한다.
|
||||||
|
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||||
|
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
|
||||||
|
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다.
|
||||||
|
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
||||||
|
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
|
||||||
|
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
|
||||||
|
- API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라 `useAdminToast` 우측 상단 토스트로 표시해 모달에 가리지 않는다.
|
||||||
|
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
|
||||||
|
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||||
|
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||||
|
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||||
|
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||||
|
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 환경 변수 (.env)
|
## 환경 변수 (.env)
|
||||||
@@ -181,34 +639,62 @@ components/content/
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=
|
DATABASE_URL=postgres://sori_studio:replace-with-random-password@sori-studio-db:5432/sori_studio
|
||||||
DATABASE_NAME=
|
DATABASE_NAME=sori_studio
|
||||||
|
POSTGRES_DB=sori_studio
|
||||||
|
POSTGRES_USER=sori_studio
|
||||||
|
POSTGRES_PASSWORD=replace-with-random-password
|
||||||
|
DB_PORT=43119
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
ADMIN_EMAIL=
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=replace-with-random-password
|
||||||
|
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||||
|
|
||||||
# Upload
|
# Upload
|
||||||
UPLOAD_DIR=/uploads
|
UPLOAD_DIR=/uploads
|
||||||
MAX_FILE_SIZE=10485760
|
MAX_FILE_SIZE=10485760
|
||||||
|
AVATAR_MIN_WIDTH=96
|
||||||
|
AVATAR_MIN_HEIGHT=96
|
||||||
|
AVATAR_MAX_WIDTH=512
|
||||||
|
AVATAR_MAX_HEIGHT=512
|
||||||
|
AVATAR_WEBP_QUALITY=82
|
||||||
|
|
||||||
# Site
|
# Site
|
||||||
SITE_URL=https://sori.studio
|
NUXT_PUBLIC_SITE_URL=https://sori.studio
|
||||||
SITE_TITLE=sori.studio
|
NUXT_PUBLIC_SITE_TITLE=sori.studio
|
||||||
|
|
||||||
|
# Server
|
||||||
|
APP_PORT=43118
|
||||||
```
|
```
|
||||||
|
|
||||||
### 환경 파일 기준
|
### 환경 파일 기준
|
||||||
|
|
||||||
| 파일 | 용도 | DB |
|
| 파일 | 용도 | DB |
|
||||||
|------|------|----|
|
|------|------|----|
|
||||||
| `.env.development` | 로컬 개발 | 개발 DB |
|
| `.env.development` | 로컬 개발, Git 제외 | 개발 DB |
|
||||||
| `.env.production` | NAS 운영 | 운영 DB |
|
| `.env.production` | NAS 운영, Git 제외 | 운영 DB |
|
||||||
| `.env.example` | 공유 예시 | 실제 접속 정보 없음 |
|
| `.env.example` | 공유 예시, Git 포함 | 실제 접속 정보 없음 |
|
||||||
|
|
||||||
|
- `.env.example`에는 실제 이메일, 비밀번호, 토큰, 운영 서버 주소를 기록하지 않음
|
||||||
|
- `.env.development`와 `.env.production`의 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||||
|
- 로컬 개발 `DATABASE_URL`은 호스트 기준 `127.0.0.1:43119`를 사용
|
||||||
|
- NAS Docker 내부 `DATABASE_URL`은 서비스명 기준 `sori-studio-db:5432`를 사용
|
||||||
|
- 로컬 DB 확인은 PostgreSQL 클라이언트에서 `127.0.0.1:43119`로 접속하거나 `docker exec sori-studio-db psql` 명령으로 확인한다.
|
||||||
|
|
||||||
|
### 포트 기준
|
||||||
|
|
||||||
|
| 용도 | 포트 |
|
||||||
|
|------|------|
|
||||||
|
| 로컬 개발 서버 | 43117 |
|
||||||
|
| NAS Docker 외부 포트 | 43118 |
|
||||||
|
| 컨테이너 내부 포트 | 3000 |
|
||||||
|
| PostgreSQL 외부 포트 | 43119 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 버전 관리
|
## 버전 관리
|
||||||
|
|
||||||
- 현재 버전: v0.0.1
|
- 현재 버전: v0.0.85
|
||||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||||
|
|||||||
74
docs/todo.md
74
docs/todo.md
@@ -2,68 +2,42 @@
|
|||||||
|
|
||||||
## 1차 관리자 개발
|
## 1차 관리자 개발
|
||||||
|
|
||||||
- [ ] 로그인 기능 구현
|
- [ ] 블록 핸들 액션 메뉴 확장: 잘라내기, 복사, 붙여넣기, 블록 타입 변경, 선택 블록 서식 적용
|
||||||
- [ ] 글 목록 조회
|
|
||||||
- [ ] 글 작성/수정 (마크다운 에디터)
|
|
||||||
- [ ] 글 발행/비공개 전환
|
|
||||||
- [ ] 태그 관리 (생성/수정/삭제)
|
|
||||||
- [ ] 이미지 업로드
|
|
||||||
|
|
||||||
## 2차 관리자 개발
|
## 2차 관리자 개발
|
||||||
|
|
||||||
- [ ] 페이지 관리 (CRUD)
|
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||||
- [ ] 사이트 설정
|
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
|
||||||
- [ ] 메뉴/네비게이션 관리
|
|
||||||
- [ ] 미디어 라이브러리
|
|
||||||
|
|
||||||
## 3차 관리자 개발
|
|
||||||
|
|
||||||
- [ ] 예약 발행
|
|
||||||
- [ ] 초안 자동 저장
|
|
||||||
- [ ] SEO 설정
|
|
||||||
- [ ] OG 이미지 설정
|
|
||||||
- [ ] 글 미리보기
|
|
||||||
|
|
||||||
## 프론트엔드 개발
|
## 프론트엔드 개발
|
||||||
|
|
||||||
- [ ] SiteHeader.vue 구현 (57px 높이)
|
- [ ] Thred 참고 화면 기준 시각 QA
|
||||||
- [ ] LeftSidebar.vue 구현 (287px, 패딩 12px 12px 12px 0)
|
- [ ] 사이드바 토글 애니메이션 세부 조정
|
||||||
- [ ] RightSidebar.vue 구현 (287px, 패딩 20px 0 20px 20px)
|
- [ ] 사용자 화면 테마 전환 초기 로드 깜빡임(FART) 최소화
|
||||||
- [ ] MainColumn.vue 구현 (720px)
|
- [ ] 회원 설정 썸네일 수동 크롭 UI(선택 영역 지정) 도입 검토
|
||||||
- [ ] PostCard.vue 구현
|
|
||||||
- [ ] TagHeader.vue 구현
|
|
||||||
|
|
||||||
## 콘텐츠 스타일 구현
|
## 콘텐츠 스타일 구현
|
||||||
|
|
||||||
- [ ] ProseHeading (h1~h6)
|
- [ ] ProseHeading 실제 스타일 세부 조정
|
||||||
- [ ] ProseList (Ordered/Unordered)
|
- [ ] ProseList 실제 스타일 세부 조정
|
||||||
- [ ] ProseBlockquote
|
- [ ] ProseBlockquote 실제 스타일 세부 조정
|
||||||
- [ ] ProseImage (Regular/Wide/Full-width)
|
- [ ] ProseImage Regular/Wide/Full-width 동작 검증
|
||||||
- [ ] ProseButton (Left-aligned/Centered)
|
- [ ] ProseImage Wide/Full-width 화면 이탈과 스킨별 최대 폭 기준 재정리
|
||||||
- [ ] ProseCallout
|
- [ ] ProseButton Left/Center 정렬 검증
|
||||||
- [ ] ProseToggle
|
- [ ] ProseCallout 실제 스타일 세부 조정
|
||||||
- [ ] ProseVideo
|
- [ ] ProseToggle 실제 스타일 세부 조정
|
||||||
- [ ] ProseAudio
|
- [ ] ProseVideo 실제 임베드 렌더링 연결
|
||||||
- [ ] ProseFile
|
- [ ] ProseAudio 실제 오디오 렌더링 연결
|
||||||
- [ ] ProseProduct
|
- [ ] ProseFile 실제 파일 데이터 연결
|
||||||
- [ ] ProseHeaderCard (Simple/Wide/Full-width/Split)
|
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||||
- [ ] ProseEmbed (YouTube, Twitter)
|
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
||||||
- [ ] Posts 테이블 설계
|
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
|
||||||
- [ ] Pages 테이블 설계
|
- [ ] 이전에 원격에 올라간 관리자 비밀번호가 실제 사용 값이면 즉시 폐기 및 변경
|
||||||
- [ ] Tags 테이블 설계
|
|
||||||
- [ ] PostTags 테이블 설계
|
|
||||||
- [ ] 로컬 개발 DB 연결 설정 작성
|
|
||||||
- [ ] NAS 운영 DB 연결 설정 작성
|
|
||||||
- [ ] 개발 DB와 운영 DB 분리 검증 절차 작성
|
|
||||||
- [ ] CloudBeaver 등 DB 관리 도구 연결 방식 결정
|
|
||||||
|
|
||||||
## 배포
|
## 배포
|
||||||
|
|
||||||
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
- [ ] NAS 운영 환경 변수 최종 점검
|
||||||
- [ ] 로컬 개발 환경 가이드 작성
|
- [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA
|
||||||
- [ ] Dockerfile 작성
|
|
||||||
- [ ] docker-compose.yml 작성
|
|
||||||
- [ ] .env.example 작성
|
|
||||||
|
|||||||
974
docs/update.md
974
docs/update.md
@@ -1,5 +1,979 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.0.0
|
||||||
|
|
||||||
|
- 운영 환경에서 `DATABASE_URL` 누락 시 샘플 콘텐츠 fallback 대신 즉시 실패하도록 수정.
|
||||||
|
- 회원 세션 서명 비밀값을 `ADMIN_PASSWORD` fallback 없이 `MEMBER_SESSION_SECRET` 필수 사용으로 분리.
|
||||||
|
- JavaScript 문법 점검 스크립트(`scripts/check-js-syntax.js`)와 `lint`·`test`·`verify` npm 스크립트 추가.
|
||||||
|
- `npm audit fix`로 Nitro 취약점 권고를 반영하고 취약점 0건 확인.
|
||||||
|
- Docker compose 설정 검증과 Docker 앱 이미지 빌드 검증 진행.
|
||||||
|
- `.env.production`의 `MEMBER_SESSION_SECRET` 설정 여부 확인 후 배포 todo 정리.
|
||||||
|
- 운영 시작 기준 버전 `1.0.0`으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.121
|
||||||
|
|
||||||
|
- 게시글 작성 본문 위 자동 저장 안내 배너를 제거하고, 툴바 상태 문구 옆에 복원·무시 버튼을 두도록 변경.
|
||||||
|
- 패키지 버전 `0.0.121`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.120
|
||||||
|
|
||||||
|
- 발행 모달 설정을 행 단위 접기/펼침으로 정리하고, 접힌 상태에서는 현재 선택값만 표시하도록 변경.
|
||||||
|
- 발행 모달에 Ghost와 동일한 SVG(게시 형태·펼침 화살표·발행 시점 시계)를 적용하고 본문·헤더·CTA 문구를 한글로 통일.
|
||||||
|
- 발행 설정 영역의 외곽 상하 보더를 제거하고 행 사이 구분선만 유지하도록 조정.
|
||||||
|
- 발행 모달 본문 영역을 가로 중앙 정렬(`max-w` 컬럼)로 맞춤.
|
||||||
|
- 패키지 버전 `0.0.120`으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.119
|
||||||
|
|
||||||
|
- 게시물 저장 버튼 클릭 시 고스트 스타일의 전체 화면 발행 모달을 열도록 변경.
|
||||||
|
- 발행 모달에서 뉴스레터 옵션을 제거하고, 상태(발행/초안/비공개)를 버튼식으로 선택하도록 추가.
|
||||||
|
- 발행 모달에서 발행 시점(즉시/예약) 버튼 선택과 예약 시각 입력을 지원하도록 추가.
|
||||||
|
- 발행 모달의 `Continue, final review →` 확정 시 실제 저장/발행이 수행되도록 연결.
|
||||||
|
- 관리자 블록 에디터 콜아웃에 Emoji ON/OFF, 이모지 프리셋 선택, 배경 프리셋 선택 기능 추가.
|
||||||
|
- 콜아웃 마크다운 저장 형식을 `:::callout emoji=... bg=...` 메타 포함 형태로 확장.
|
||||||
|
- 공개 본문 콜아웃 렌더러에 이모지 표시/숨김과 배경 프리셋 렌더링 연결.
|
||||||
|
- 공개 콜아웃 카드 외부 여백을 상단 중심(`mt-8`)으로 조정.
|
||||||
|
- 관리자 콜아웃 편집 UI를 카드 내부에서 분리해 우측 고정 설정 패널로 이동하고, 편집 카드가 공개 렌더와 동일하게 보이도록 정리.
|
||||||
|
- 콜아웃 카드 보더를 제거해 Ghost 톤으로 정리하고, 콜아웃 설정 패널을 블록 옆 위치로 이동.
|
||||||
|
- 콜아웃 이모지 설정을 고정 프리셋 버튼만 사용하지 않고 입력 팝업(직접 입력/붙여넣기 + 빠른 선택) 방식으로 확장.
|
||||||
|
- 콜아웃 본문 왼쪽 이모지 버튼에서 이모지 입력 팝오버를 직접 여는 흐름으로 정리.
|
||||||
|
- 배경색은 컬러 버튼 클릭 시에만 팔레트 팝오버가 열리도록 단순화하고, 텍스트 색상을 관리자 화면에서 고정 진한 톤으로 보정.
|
||||||
|
- 콜아웃 이모지 입력을 가변 길이 contenteditable에서 단일 이모지 입력 필드로 정리하고, 첫 그래프림만 반영하도록 보정.
|
||||||
|
- 공개 콜아웃의 이모지·텍스트 정렬을 `items-center` 기준으로 조정해 관리자 편집 카드와 높이 체감을 맞춤.
|
||||||
|
- 콜아웃 이모지 입력 필드에 한글 IME 조합 종료 시점 반영을 추가해 `가` 입력 시 자모 분리 대신 완성형 문자로 저장되도록 보정.
|
||||||
|
- 관리자 블록 에디터에서 Enter 등 키보드 입력 직후 hover 강조를 잠시 비활성화하고, 마우스 이동 시 hover가 다시 동작하도록 조정.
|
||||||
|
- 키보드 우선 모드에서 블록 왼쪽 핸들(세로 마커) hover 표시도 함께 비활성화해, 포인터가 다른 문단 위에 있어도 현재 입력 문맥을 유지하도록 보정.
|
||||||
|
- 블록 에디터 슬래시 입력 상태에서 Enter 처리 조건을 보정해, `/` 또는 `//` 입력 상태에서는 엔터가 일반 줄바꿈/다음 문단 생성으로 동작하도록 수정.
|
||||||
|
- 한글 IME 조합 중 `/제목` 입력 뒤 Enter 시 조합 종료 직후 슬래시 명령이 바로 적용되도록 pending 명령 처리 추가.
|
||||||
|
- 빈 문단 삭제 동작 이후 콜아웃이 일반 문단으로 변환되는 경로를 차단하기 위해 Enter의 빈 블록 기본 문단 전환 대상에서 콜아웃을 제외.
|
||||||
|
- 한글 IME 조합 종료 직후 슬래시 메뉴가 늦게 뜨는 문제를 줄이기 위해 조합 종료 동기화를 즉시+지연 2회 수행하도록 보정.
|
||||||
|
- 게시물 상세에서 본문과 댓글 섹션 사이 간격을 Ghost 기준 `48px`(`mt-12`)으로 조정.
|
||||||
|
- 이미지/갤러리 블록을 생성만 하고 사용하지 않은 상태로 다른 블록으로 이동하면 해당 미사용 구조형 블록을 자동 정리하도록 보정.
|
||||||
|
|
||||||
|
## v0.0.118
|
||||||
|
|
||||||
|
- 관리자 게시글 저장 버튼을 변경사항이 있을 때만 활성화하도록 수정.
|
||||||
|
- 관리자 게시글 수정 삭제 버튼을 기본 중립 톤, hover 시 위험 색상 강조로 조정.
|
||||||
|
- 관리자 게시글 태그 배지 삭제 버튼을 SVG 아이콘으로 교체하고 정렬 보정.
|
||||||
|
- 패키지 버전 `0.0.118`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.117
|
||||||
|
|
||||||
|
- 관리자 글쓰기 갤러리 미디어 선택을 복수 선택 후 확인 적용 방식으로 변경.
|
||||||
|
- 관리자 갤러리 블록의 이미지 수에 따라 1개는 전체 너비, 2개는 2열, 3개 이상은 3열로 표시하도록 수정.
|
||||||
|
- 관리자 갤러리 블록 이미지 드래그 순서 변경과 삽입 위치 표시 추가.
|
||||||
|
- 패키지 버전 `0.0.117`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.116
|
||||||
|
|
||||||
|
- 관리자 게시글 제목 입력에서 한글 조합 중 Enter가 본문으로 마지막 글자를 넘기지 않도록 IME 조합 상태 가드 추가.
|
||||||
|
- 관리자 게시글 목록 태그 표시를 쉼표 구분 텍스트에서 읽기 전용 배지 목록으로 변경.
|
||||||
|
- 패키지 버전 `0.0.116`으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.115
|
||||||
|
|
||||||
|
- 사용자 설정 화면을 좁은 공개 본문 폭에 맞춰 요약 영역 상단, 프로필·활동 영역 하단 구조로 재배치.
|
||||||
|
- 사용자 설정 비밀번호 변경·회원 탈퇴 모달 입력 필드 보더가 보이도록 정리.
|
||||||
|
- 관리자 사이트 설정에서 관리자 프로필·관리자 비밀번호 변경 섹션 제거.
|
||||||
|
- 관리자 사이트 설정에 1:1 로고 이미지 업로드와 파비콘 생성 기능 추가.
|
||||||
|
- 사이트 설정 로고 URL·파비콘 URL 저장 컬럼 마이그레이션(`022_add_site_logo_urls.sql`) 추가.
|
||||||
|
- 공개 헤더와 오른쪽 사이드바에 이미지 로고 표시를 연결하고 파비콘 head 링크를 추가.
|
||||||
|
- 패키지 버전 `0.0.115`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.114
|
||||||
|
|
||||||
|
- 관리자 하단 사용자 메뉴의 `내 프로필` 경로를 사용자 설정에서 관리자 멤버 편집 화면으로 변경.
|
||||||
|
- 관리자 멤버 편집 설정 메뉴에 비밀번호 직접 변경과 멤버 삭제 모달 추가.
|
||||||
|
- 사용자 설정 화면을 관리자 멤버 편집과 같은 요약/본문 구조로 재정리.
|
||||||
|
- 사용자 설정의 비밀번호 변경과 회원 탈퇴를 설정 메뉴 모달로 분리.
|
||||||
|
- 로그인 시 이전 로그인 시각/IP를 보존하는 `021_add_member_previous_login.sql` 마이그레이션 추가.
|
||||||
|
- 회원 프로필 API에 가입일, 이전 로그인, 댓글 수 정보를 추가.
|
||||||
|
- 패키지 버전 `0.0.114`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.113
|
||||||
|
|
||||||
|
- 관리자 미저장 변경사항 모달을 화면 상단 40px 여백 위치로 조정.
|
||||||
|
- 관리자 멤버 상세 폼에서 썸네일 URL 입력 필드 제거.
|
||||||
|
- 관리자 멤버 상세 요약 썸네일에 hover 등록·변경·삭제 UI 추가.
|
||||||
|
- 관리자 멤버 목록 상태 표시를 배지에서 일반 텍스트로 변경.
|
||||||
|
- 관리자 멤버 목록에 이름·이메일·레이블·상태·최근 접속·가입일 조건 필터 추가.
|
||||||
|
- 패키지 버전 `0.0.113`으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.112
|
||||||
|
|
||||||
|
- 관리자 멤버 폼 본문을 3분할 그리드로 변경하고 요약 1fr, 입력 영역 2fr 비율로 조정.
|
||||||
|
- 관리자 공통 미저장 변경사항 이탈 확인 모달 추가.
|
||||||
|
- 관리자 게시글 작성/수정 화면에 미저장 변경사항 라우트 이탈 확인과 브라우저 이탈 기본 확인 연결.
|
||||||
|
- 관리자 멤버 추가/수정 화면에 미저장 변경사항 라우트 이탈 확인과 브라우저 이탈 기본 확인 연결.
|
||||||
|
- 이탈 승인 시 게시글 로컬 자동 저장본 삭제 처리 추가.
|
||||||
|
- 패키지 버전 `0.0.112`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.111
|
||||||
|
|
||||||
|
- 관리자 멤버 상세 화면(`/admin/members/:id`) 추가.
|
||||||
|
- 관리자 멤버 추가 화면(`/admin/members/new`) 추가.
|
||||||
|
- 멤버 목록 행 클릭 시 상세 화면으로 이동하도록 수정.
|
||||||
|
- 멤버 기본 정보 저장 API(`GET/PUT /admin/api/members/:id`, `POST /admin/api/members`) 추가.
|
||||||
|
- 회원 레이블·관리자 노트 저장 컬럼 마이그레이션(`020_add_member_admin_fields.sql`) 추가.
|
||||||
|
- 멤버 폼에 썸네일 URL, 이름, 이메일, 레이블, 관리자 노트, 기존 회원 활동 요약 표시.
|
||||||
|
- 패키지 버전 `0.0.111`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.110
|
||||||
|
|
||||||
|
- 관리자 멤버 목록을 Ghost형 테이블 구조로 재정리.
|
||||||
|
- 멤버 이름 아래 이메일, 가입일 아래 최근 활동을 함께 표시하도록 수정.
|
||||||
|
- 멤버 목록에서 권한 변경 선택·저장 UI 제거.
|
||||||
|
- 멤버 검색 입력과 멤버 추가 버튼 추가.
|
||||||
|
- 뉴스레터 Open rate 대체 컬럼으로 댓글 작성 개수 표시 유지.
|
||||||
|
- 패키지 버전 `0.0.110`으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.109
|
||||||
|
|
||||||
|
- 관리자 사이드바 `메뉴` 항목을 `네비게이션`으로 변경하고 전용 아이콘 적용.
|
||||||
|
- 관리자 게시글 아이콘을 Ghost형 편집 아이콘으로 교체.
|
||||||
|
- 관리자 멤버 메뉴 우측에 총 멤버 수 표시 추가.
|
||||||
|
- 관리자 로그아웃을 상단 메뉴에서 제거하고 하단 사용자 드롭다운으로 이동.
|
||||||
|
- 관리자 하단에 사용자 썸네일 트리거와 설정 아이콘 추가.
|
||||||
|
- 패키지 버전 `0.0.109`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.108
|
||||||
|
|
||||||
|
- 관리자 사이드바 너비를 Ghost 기준 320px로 조정.
|
||||||
|
- 관리자 우측 캔버스가 기본 화면 높이와 배경색을 유지하도록 `admin-layout__main` 배경·여백 수정.
|
||||||
|
- 관리자 사이드바 페이지·미디어·설정 아이콘 추가.
|
||||||
|
- 패키지 버전 `0.0.108`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.107
|
||||||
|
|
||||||
|
- 관리자 사이드바를 밝은 Ghost형 톤으로 조정.
|
||||||
|
- 관리자 `글` 메뉴명을 `게시글`로 변경하고 게시글·태그·멤버 메뉴 아이콘 추가.
|
||||||
|
- 게시글 메뉴 우측 `+` 버튼으로 `/admin/posts/new` 바로 진입 추가.
|
||||||
|
- 패키지 버전 `0.0.107`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.106
|
||||||
|
|
||||||
|
- 태그 상세(`/tag/:slug`) 헤더와 게시물 목록을 공통 `site-section-header`·`site-section-body` 패딩 구조로 맞춤.
|
||||||
|
- 태그 상세 게시물 목록의 중복 구분선을 정리.
|
||||||
|
- 패키지 버전 `0.0.106`으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.105
|
||||||
|
|
||||||
|
- `005_add_navigation_items.sql`: `(location,label,url)` 유니크 제약 제거 후에도 기본 메뉴가 재삽입되지 않도록 `NOT EXISTS` 기반 시드로 수정.
|
||||||
|
- `019_dedupe_navigation_items.sql`: 반복 마이그레이션 실행으로 생긴 네비게이션 중복 행 정리 및 중복 방지 인덱스 추가.
|
||||||
|
- 로컬 개발 DB 네비게이션 중복 행 정리.
|
||||||
|
- 패키지 버전 `0.0.105`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.104
|
||||||
|
|
||||||
|
- 관리자 API 요청마다 현재 DB 권한 재확인 미들웨어 추가.
|
||||||
|
- 회원 탈퇴 시 마지막 `owner` 계정 삭제 차단 및 관리자 세션 쿠키 정리.
|
||||||
|
- 최초 회원 생성 시 `users` 테이블 잠금으로 동시 가입 owner 판정 안정화.
|
||||||
|
- 이메일 OTP 발송 실패 시 방금 만든 챌린지 삭제, 발송 성공 후 이전 pending 챌린지 정리.
|
||||||
|
- 본문 북마크·임베드 외부 링크를 `http(s)` URL로 제한.
|
||||||
|
- 패키지 버전 `0.0.104`로 갱신.
|
||||||
|
|
||||||
|
## v0.0.103
|
||||||
|
|
||||||
|
- `docs/map.md`: 관리자 메뉴 관리 행 설명에서 제거된 마이그레이션 안내 문구 반영.
|
||||||
|
|
||||||
|
## v0.0.102
|
||||||
|
|
||||||
|
- `AdminBlockEditor`: 빈 단락은 HTML 주석 마커로 직렬화·복원, 슬래시 메뉴 하이라이트·스크롤·긴 목록 `max-h`·블록 경계에서 위/아래 방향키로 인접 블록 이동.
|
||||||
|
- `ContentMarkdownRenderer`: 동일 마커 줄을 빈 단락으로 표시.
|
||||||
|
- `SiteHeader`: 검색 열기 버튼에 `min-w-[470px]`(md 이상)로 최소 폭 고정.
|
||||||
|
- `pages/admin/navigation`: 상단 마이그레이션 안내 문단 제거.
|
||||||
|
|
||||||
|
## v0.0.101
|
||||||
|
|
||||||
|
- `server/utils/navigation-tree.js` `buildPublicPrimaryTree`: 평면 `primary`에서 **동일 id 중복 제거**(정렬 후 첫 행 유지), **유효 부모 아래에 붙은 항목은 루트에서 제외**해 사이드바에 항목이 두 번 보이던 현상 방지.
|
||||||
|
|
||||||
|
## v0.0.100
|
||||||
|
|
||||||
|
- `.env.example`·`docs/deploy.md`·`docs/spec.md`: **`EMAIL_OTP_PEPPER` 의미**(OTP 해시용 서버 비밀, 긴 난문자열 권장·미설정 시 `MEMBER_SESSION_SECRET` 사용) 문구 보강.
|
||||||
|
|
||||||
|
## v0.0.99
|
||||||
|
|
||||||
|
- `SiteHeader`: 헤더 내부 `grid-cols-3`로 검색 패널 **중앙 열 정렬**(Ghost류 레이아웃).
|
||||||
|
- Resend 기반 **이메일 OTP**: 마이그레이션 `018_email_otp_challenges.sql`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`, `signup`·`bootstrap-status` 연동.
|
||||||
|
- `pages/forgot-password.vue`, `signin`에서 비밀번호 찾기 링크. `getUserByEmail`·로그인 이메일 **대소문자 무시** 조회.
|
||||||
|
- `nuxt.config` `runtimeConfig`: `resendApiKey`, `resendFromEmail`, `emailOtpPepper`.
|
||||||
|
|
||||||
|
## v0.0.98
|
||||||
|
|
||||||
|
- `SidebarPrimaryNavList`: `navRowShell`에 **`w-full`** 추가해 `site-panel-hover` 배경이 행 가로 전체를 쓰도록 함.
|
||||||
|
- `LeftSidebar`: 푸터 API `footer` 링크 **`flex-wrap`**·`nav` **`min-w-0 flex-1`**, 링크·테마 버튼 **`shrink-0`**로 항목이 많을 때 가로 오버플로 방지.
|
||||||
|
|
||||||
|
## v0.0.97
|
||||||
|
|
||||||
|
- `SidebarPrimaryNavList`: 하위 폴더 부모도 리프와 동일 **세로 막대→호버 원형** `before` 장식. 내부 링크가 현재 경로와 일치하면 장식을 **`--site-accent`**로 표시(`aria-current="page"`).
|
||||||
|
|
||||||
|
## v0.0.96
|
||||||
|
|
||||||
|
- `SidebarPrimaryNavList`: 하위가 있는 부모는 **행 전체 버튼**으로 토글, chevron **회전 애니메이션**, 하위 영역 **높이 전환 애니메이션**, 펼침 전용 배경 제거·너비·간격 리프와 통일.
|
||||||
|
|
||||||
|
## v0.0.95
|
||||||
|
|
||||||
|
- 메뉴 관리: `표시`·`폴더` 체크 제거(항목은 항상 공개, `is_folder`는 저장 시 자식 유무로 서버 설정). 테이블+행 드래그 UX를 태그 메인 태그와 동일 톤(`bg-[#f9f9f7]`·`opacity-50`, 입력 위에서는 드래그 시작 안 함).
|
||||||
|
- 태그 관리 메인 태그: 드래그 중인 행에 `opacity-50` 적용(메뉴 관리와 동일한 이탈 피드백).
|
||||||
|
- `PUT /admin/api/navigation`: `parent_id`/`is_folder` 컬럼 부재 시 한국어 안내(503) 및 화면에 마이그레이션 안내 블록.
|
||||||
|
|
||||||
|
## v0.0.94
|
||||||
|
|
||||||
|
- 메뉴 관리: 상단/하단 탭 분리, 순서는 드래그(태그 관리와 유사). 상단은 `parent_id` 트리·하위 추가·폴더(`is_folder`)·동일 부모 내 순서 변경.
|
||||||
|
- `GET /api/navigation`의 `primary`는 트리 응답, 좌측 사이드바는 `SidebarPrimaryNavList`로 접기/펼치기(`localStorage` 유지).
|
||||||
|
- 마이그레이션 `017_navigation_hierarchy.sql`, 공유 `lib/navigation-editor-tree.js`, `server/utils/navigation-tree.js` 검증·저장 순서.
|
||||||
|
|
||||||
|
## v0.0.93
|
||||||
|
|
||||||
|
- `composables/useAdminToast.js` 추가: 관리자 우측 상단 토스트(자동 숨김).
|
||||||
|
- 관리자 미디어(`pages/admin/media/index.vue`): 본문 상단 `errorMessage` 배너 제거, 폴더·이름 변경·삭제 등 실패 피드백을 토스트로 통일해 모달에 가리지 않게 함.
|
||||||
|
|
||||||
|
## v0.0.92
|
||||||
|
|
||||||
|
- 회원 `PUT /api/auth/profile`에서 관리 썸네일 URL이 바뀌거나 비워질 때도 `removeManagedAvatarAsset`으로 메타만 분리해, 해제 후에도 디스크·썸네일 탭 목록과 일치하도록 정리.
|
||||||
|
- 관리자 미디어 상세 모달에 **다운로드** 버튼 추가.
|
||||||
|
- 썸네일 탭 안내: 프로필 해제 시에도 파일이 삭제되지 않음·목록 갱신은 새로고침을 명시.
|
||||||
|
|
||||||
|
## v0.0.91
|
||||||
|
|
||||||
|
- 회원 썸네일 교체·삭제·탈퇴 시 이전 파일은 디스크에 남기고 `media_metadata`만 제거해, 관리자 썸네일 탭에서 미사용 자산을 구분·삭제할 수 있게 함.
|
||||||
|
- 관리자 미디어: 프로필이 참조 중인 썸네일만 삭제·이름 변경 차단(미참조 파일은 허용).
|
||||||
|
- `POST /admin/api/uploads`·`POST /api/auth/avatar`: 저장 파일명은 원본명 기반, 동일 폴더 충돌 시 `-2` 넘버링.
|
||||||
|
- 관리자 미디어 검색: 파일명·게시물 사용처 제목만; 모달에서 폴더 요약 중복 행 제거.
|
||||||
|
- `renameMediaItem`: 대상 폴더에 동일 파일명이 있으면 409.
|
||||||
|
|
||||||
|
## v0.0.90
|
||||||
|
|
||||||
|
- 관리자 미디어: 상단 탭으로 **미디어 라이브러리**와 **썸네일**(회원 `/members/avatars/`만) 분리, 썸네일 검색에 닉네임·이메일·IP 반영.
|
||||||
|
- 게시물용 관리자 업로드는 디스크 `posts/연/월` 유지하되 `media_metadata` 및 목록 논리 폴더는 **`미분류`**로 통일; 회원 아바타 메타는 **`썸네일`**.
|
||||||
|
- `listMediaItems`에 `avatarOwner` 부착, 썸네일 파일의 관리자 삭제·이름 변경·임의 논리 폴더 이동 차단, 예약 폴더 `썸네일`의 `media_folders` 생성·삭제 차단.
|
||||||
|
- 마이그레이션 `016_media_category_normalize.sql`로 레거시 `posts`/`회원/썸네일` 카테고리 문자열 정리.
|
||||||
|
|
||||||
|
## v0.0.89
|
||||||
|
|
||||||
|
- 관리자 미디어 썸네일 선택 컨트롤을 네이티브 체크박스에서 대비가 분명한 토글 버튼(미선택: 흰 배경·진한 테두리, 선택: 진한 배경·흰 체크)으로 교체.
|
||||||
|
- `docs/spec.md`에 미디어 디스크 경로와 `media_metadata` 논리 폴더 관계를 명시(이후 v0.0.90에서 `미분류`/`썸네일` 정책으로 갱신).
|
||||||
|
|
||||||
|
## v0.0.88
|
||||||
|
|
||||||
|
- 관리자 미디어: 썸네일 본문 클릭 시 상세 모달, 좌측 상단 체크박스로 다중 선택(Shift 범위 유지), 툴바 `현재 폴더로 이동` 제거.
|
||||||
|
- 폴더 추가는 상시 입력 대신 모달로 이름 입력.
|
||||||
|
- `DELETE /admin/api/media-folders` 및 폴더 행 삭제 UI 추가(삭제 시 해당 분류 메타는 `미분류`로).
|
||||||
|
|
||||||
|
## v0.0.87
|
||||||
|
|
||||||
|
- 메인 태그 `정렬 저장`·메뉴 `메뉴 저장`은 서버에서 받은 상태와 비교해 변경이 있을 때만 버튼이 활성화되도록 조정.
|
||||||
|
- 관리자 로그인·회원 로그인(`signin`) 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화.
|
||||||
|
- 관리자 글 목록의 삭제는 휴지통 아이콘으로 바꾸고, 기본은 낮은 불투명도·호버 시에만 강조.
|
||||||
|
- `AGENTS.md`에 커밋 후 원격 반영 시 `git push` 생략 방지 지침을 추가.
|
||||||
|
|
||||||
|
## v0.0.86
|
||||||
|
|
||||||
|
- 관리자 게시물 미리보기 본문 영역을 공개 상세와 동일한 `max-w-[720px]`·좌우 패딩으로 감싸 여백을 맞춤.
|
||||||
|
- 글 작성 폼에서 태그 토큰은 로마자 슬러그 변환 대신 한글 유지 정규화를 사용하고, 저장 시 태그 `name`은 한글 슬러그에 맞게 표시되도록 저장소 `normalizeTagSlugs`·`getTagNameFromSlug`를 조정.
|
||||||
|
- 관리자 게시물 폼에서 SEO 제목·설명 입력을 제거하고 저장 시 제목·요약을 메타 필드로 기록하도록 변경. `admin-post-input`의 SEO 문자열 길이 제한을 완화.
|
||||||
|
- 관리자 태그 관리 화면의 성공·오류 안내를 본문 상단 블록 대신 우측 상단 고정 토스트로 표시.
|
||||||
|
|
||||||
|
## v0.0.85
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 저장 시 의도적으로 만든 빈 문단(연속 Enter)을 제거하지 않도록 빈 문단 마커(`<!--sori:blank-paragraph-->`) 직렬화/복원 로직을 추가.
|
||||||
|
- 공개 본문 마크다운 렌더러에서도 빈 문단 마커를 문단 블록으로 해석해 저장 후에도 문단 간 여백 의도를 유지하도록 맞춤.
|
||||||
|
|
||||||
|
## v0.0.84
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 본문에서 위/아래 방향키 입력 시 커서가 블록 시작/끝 경계에 있으면 이전/다음 문단(또는 구조형 블록)으로 이동하도록 보완.
|
||||||
|
- 슬래시 메뉴를 최대 높이 제한 + 내부 스크롤 구조로 변경해 명령 개수가 많아도 화면을 넘기지 않도록 조정.
|
||||||
|
- 슬래시 메뉴 방향키 이동 시 현재 하이라이트 항목이 항상 스크롤 영역 안에 보이도록 자동 스크롤을 추가.
|
||||||
|
|
||||||
|
## v0.0.83
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 슬래시 메뉴에서 방향키 이동 시 하이라이트 인덱스가 매번 초기화되던 문제를 수정해 연속 이동이 가능하도록 보정.
|
||||||
|
- 슬래시 메뉴가 열린 블록(`text`가 `/`로 시작)에서만 위/아래 방향키 메뉴 이동 로직이 동작하도록 분기해, 일반 본문 블록의 방향키 입력 간섭을 줄임.
|
||||||
|
|
||||||
|
## v0.0.82
|
||||||
|
|
||||||
|
- 메인 태그 목록의 `삭제` 버튼을 제거하고 `일반 태그로 변경`(강등) 버튼으로 교체.
|
||||||
|
- 일반 태그 삭제는 일반 태그 검색 결과 영역에서만 가능하도록 변경.
|
||||||
|
- 태그 생성/수정 폼에서 정렬 순서 입력과 태그 유형 선택을 제거해, 메인/일반 전환은 태그 목록 액션으로만 처리하도록 단순화.
|
||||||
|
|
||||||
|
## v0.0.81
|
||||||
|
|
||||||
|
- 관리자 글 작성 태그 입력에서 한글 조합 중 Enter 입력 시 중복 태그가 생성되지 않도록 IME 조합 상태 가드를 추가.
|
||||||
|
- 게시물 저장 중 새로 생성되는 태그는 기본값을 `general`(일반 태그)로 저장하도록 수정.
|
||||||
|
- 관리자 태그 화면에서 `관리용 태그` 명칭을 `메인 태그`로 변경.
|
||||||
|
- 관리자 태그 화면의 일반 태그 전체 목록 테이블을 제거하고, 일반 태그 검색 후 `메인 태그로 전환`하는 흐름으로 개편.
|
||||||
|
- 관리자 태그 목록 API에 `tagType`, `q`, `limit` 조회 옵션을 추가해 일반 태그 검색을 지원.
|
||||||
|
|
||||||
|
## v0.0.80
|
||||||
|
|
||||||
|
- 태그에 유형(`managed`/`general`) 컬럼을 추가하는 마이그레이션(`015_add_tag_type_and_reorder_support.sql`)을 추가.
|
||||||
|
- 관리자 태그 입력 폼에 태그 유형 선택을 추가하고, 일반 태그는 정렬 순서를 사용하지 않도록 정리.
|
||||||
|
- 관리자 태그 목록을 `관리용 태그`/`일반 태그`로 분리.
|
||||||
|
- 관리용 태그 목록에 드래그 앤 드롭 정렬 UI를 추가하고, 저장 버튼으로 순서를 일괄 반영하도록 개선.
|
||||||
|
- `PUT /admin/api/tags/reorder` API를 추가해 관리용 태그의 `sort_order`를 순차 재정렬하도록 확장.
|
||||||
|
- 공개 태그 API(`GET /api/tags`)는 관리용 태그만 반환하도록 변경해 좌측 카테고리 영역과 태그 관리 목적을 분리.
|
||||||
|
|
||||||
|
## v0.0.79
|
||||||
|
|
||||||
|
- 댓글 목록에 작성자 썸네일(`avatar_url`)을 노출하고, 썸네일이 없으면 이니셜 아바타를 표시하도록 UI를 개선.
|
||||||
|
- 댓글/대댓글에 좋아요 토글을 추가하고, 좋아요 수/내 좋아요 상태를 함께 반환하도록 API와 저장소를 확장.
|
||||||
|
- 댓글 시간 표기를 `n분 전`, `n시간 전`, 24시간 이후 날짜 형식으로 변경.
|
||||||
|
- `comment_likes` 테이블 마이그레이션(`012_add_comment_likes.sql`)을 추가.
|
||||||
|
- 댓글 정렬 옵션(`Best`, `Latest`, `Oldest`)을 실제 동작하도록 연결.
|
||||||
|
- 댓글/대댓글 카드 보더를 최소화하고 간격·아이콘 스타일을 정리해 더 단정한 레이아웃으로 조정.
|
||||||
|
- 대댓글 아바타가 깨질 때 이니셜로 안전하게 대체되도록 이미지 로드 실패 처리 보강.
|
||||||
|
- 공개 레이아웃(`default`, `post`)의 데스크톱 3열 `gap-x`를 제거해 본문-사이드 사이가 이중 패딩처럼 보이던 간격을 정리.
|
||||||
|
- 좌측 사이드바 네비게이션/카테고리 hover 배경을 `site-panel-hover` 기반으로 통일해 다크 모드에서 텍스트 가독성 저하를 수정.
|
||||||
|
- 관리자 멤버 목록에 권한 컬럼을 추가하고, 환경변수 관리자 계정을 `관리자` 권한으로 함께 표시하도록 보강.
|
||||||
|
- 사용자 테이블에 관리자 권한 컬럼(`is_admin`)을 추가하는 마이그레이션(`013_add_user_admin_role.sql`)을 추가.
|
||||||
|
- 최초 사용자 생성 시 관리자 권한을 자동 부여하고, 가입 응답에 관리자 여부를 함께 반환하도록 수정.
|
||||||
|
- 관리자 로그인은 환경변수 고정 계정 대신 `is_admin` 회원 계정 인증으로 전환.
|
||||||
|
- `GET /api/auth/bootstrap-status`를 추가해 최초 관리자 등록 필요 여부를 조회하도록 확장.
|
||||||
|
- 회원가입 화면에서 최초 사용자일 때 `관리자 등록` 타이틀/설명/필드 라벨을 노출하도록 수정.
|
||||||
|
- 관리자 로그인 시 회원 세션도 함께 설정하고, 관리자 로그아웃 시 회원 세션도 함께 정리하도록 수정.
|
||||||
|
- 관리자 설정 화면에 관리자 프로필(썸네일 업로드/제거, 이름 저장) 섹션을 추가.
|
||||||
|
- 관리자 설정 화면에서 현재 비밀번호 확인 기반 관리자 비밀번호 변경을 지원하도록 추가.
|
||||||
|
- 회원 권한 3단계(`owner`/`admin`/`member`)를 위한 마이그레이션(`014_add_user_role_levels.sql`)을 추가.
|
||||||
|
- 관리자 멤버 화면에서 권한 선택/저장 UI와 `PUT /admin/api/members/:id/role` 권한 변경 API를 추가.
|
||||||
|
|
||||||
|
## v0.0.78
|
||||||
|
|
||||||
|
- 관리자 미디어 목록에서 회원 썸네일 경로(`/uploads/members/avatars/`)를 다시 포함해 `회원/썸네일` 폴더에서 확인 가능하도록 수정.
|
||||||
|
- 회원 썸네일 파일도 일반 미디어와 동일하게 폴더 트리/검색/카운트 집계에 반영되도록 정리.
|
||||||
|
|
||||||
|
## v0.0.77
|
||||||
|
|
||||||
|
- 회원 설정 프로필 영역에서 썸네일 URL 입력을 제거하고, 썸네일 미리보기 + 이미지 변경/제거 버튼 중심 UI로 개편.
|
||||||
|
- 썸네일 업로드 완료 안내 문구를 실제 동작과 맞게 간소화.
|
||||||
|
- 썸네일 수동 크롭 UI는 우선순위 낮은 후속 작업으로 `docs/todo.md`에 기록.
|
||||||
|
|
||||||
|
## v0.0.76
|
||||||
|
|
||||||
|
- 회원 썸네일 업로드 시 원본 비율과 관계없이 중앙 기준 1:1 정사각형으로 강제 크롭하도록 변경.
|
||||||
|
- 크롭 결과 해상도는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 정사각형(`N x N`)으로 저장하도록 정리.
|
||||||
|
|
||||||
|
## v0.0.75
|
||||||
|
|
||||||
|
- 회원 썸네일 업로드 시 최소 해상도 제한(`AVATAR_MIN_WIDTH`, `AVATAR_MIN_HEIGHT`)을 추가해 너무 작은 이미지를 차단.
|
||||||
|
- 썸네일 품질과 리사이즈 설정값이 비정상일 때 서버에서 안전 범위로 보정(clamp)하도록 방어 로직을 추가.
|
||||||
|
- 최대 해상도 설정이 최소 해상도보다 작게 들어와도 자동 보정되도록 처리.
|
||||||
|
|
||||||
|
## v0.0.74
|
||||||
|
|
||||||
|
- 회원 썸네일 업로드 시 원본 포맷과 관계없이 WebP로 변환 저장하도록 수정.
|
||||||
|
- 회원 썸네일 업로드 시 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 기준으로 자동 리사이즈(`fit: inside`)를 적용.
|
||||||
|
- 썸네일 품질을 `AVATAR_WEBP_QUALITY` 환경 변수로 제어하도록 설정을 추가.
|
||||||
|
|
||||||
|
## v0.0.73
|
||||||
|
|
||||||
|
- 회원 썸네일 교체 시 기존 회원 전용 썸네일 파일/메타데이터를 자동 정리하는 공통 유틸을 추가.
|
||||||
|
- `DELETE /api/auth/avatar`를 추가해 회원 설정에서 썸네일 제거를 지원하고, 제거 시 프로필 `avatarUrl`을 비움.
|
||||||
|
- 회원 탈퇴 시에도 회원 전용 썸네일 파일/메타데이터가 함께 정리되도록 처리.
|
||||||
|
|
||||||
|
## v0.0.72
|
||||||
|
|
||||||
|
- 회원 썸네일 업로드 API(`POST /api/auth/avatar`)를 추가하고 업로드 경로를 `/uploads/members/avatars/YYYY/MM`으로 분리.
|
||||||
|
- 회원 설정 페이지에서 썸네일 파일 업로드를 직접 처리하고, 업로드 후 프로필 저장 흐름으로 연결.
|
||||||
|
- 관리자 미디어 목록에서 회원 썸네일은 전용 카테고리(`회원/썸네일`)로 구분해 관리.
|
||||||
|
|
||||||
|
## v0.0.71
|
||||||
|
|
||||||
|
- 헤더 사용자 영역에서 구독 버튼을 제거하고, 로그인 상태 기반 아바타/드롭다운(설정, 로그아웃 / 비로그인 시 Sign up, Sign in)으로 정리.
|
||||||
|
- 회원 설정 페이지(`/settings`)를 추가하고 닉네임 변경(중복 확인), 썸네일 URL 변경, 비밀번호 변경, 회원 탈퇴 기능을 연결.
|
||||||
|
- 관리자 멤버 화면(`/admin/members`)과 회원 목록 API를 추가해 닉네임, 이메일, 최근 접속 시각/IP, 댓글 개수, 활동 현황을 확인할 수 있게 구성.
|
||||||
|
- 회원 활동 컬럼(`last_seen_at`, `last_seen_ip`)과 아바타 컬럼(`avatar_url`)을 DB에 추가하고, 로그인/세션 조회/댓글 작성 시 최근 활동을 갱신.
|
||||||
|
|
||||||
|
## v0.0.70
|
||||||
|
|
||||||
|
- 회원 인증 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)와 회원 세션 쿠키(`sori_member_session`)를 추가.
|
||||||
|
- 댓글 DB 스키마(`users`, `comments`)와 게시물 댓글 API(`GET/POST /api/posts/:slug/comments`)를 추가하고, 대댓글은 1단까지만 허용하도록 검증을 적용.
|
||||||
|
- 게시물 상세 `#comments` 영역에 `PostComments` UI를 연결해 로그인 회원 댓글/답글 작성과 댓글 목록 표시를 구현.
|
||||||
|
- `signin`/`signup` 화면을 시뮬레이션에서 실제 API 연동으로 전환.
|
||||||
|
|
||||||
|
## v0.0.66
|
||||||
|
|
||||||
|
- 태그 검색은 `description`을 제외하고 `name`·`slug`만 부분 일치하도록 조정해, `p` 같은 한 글자 입력으로 의미 없는 태그가 뜨는 혼선을 줄임.
|
||||||
|
- 검색 모달 헤더 아이콘은 입력 비어있으면 돋보기, 입력이 있으면 X(클리어)로 전환하고 클릭 시 입력값을 비운다. 좌측/우측 닫기 X는 제거하고 `Esc`·백드롭 클릭·모바일 취소로 닫는다.
|
||||||
|
- 검색 입력은 IME(한글 조합) 중에도 디바운스로 검색을 갱신해 `워`처럼 조합 상태가 유지되는 입력에서도 결과가 나오게 하고, 조합 종료 시점에는 확정값으로 즉시 한 번 더 갱신한다.
|
||||||
|
|
||||||
|
## v0.0.67
|
||||||
|
|
||||||
|
- 공개 검색에서 `/uploads/...` 파일 경로와 마크다운 이미지 토큰(``)은 노이즈로 간주해 매칭 대상에서 제거, 이미지 파일명 때문에 숫자 검색이 걸리던 문제를 해결.
|
||||||
|
|
||||||
|
## v0.0.68
|
||||||
|
|
||||||
|
- 게시물 상세(`pages/post/[slug].vue`) 섹션 패딩을 내부 래퍼로 이동해 레이아웃 그리드 패딩과 중복되던 2중 패딩을 제거.
|
||||||
|
- 댓글 섹션 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 적용.
|
||||||
|
|
||||||
|
## v0.0.69
|
||||||
|
|
||||||
|
- 공개 레이아웃(`layouts/default.vue`, `layouts/post.vue`)에서 메뉴 닫힘 시 추가로 주던 메인 우측 패딩을 제거해 가로 패딩이 1회만 적용되도록 정리.
|
||||||
|
- 홈/태그 목록/태그 상세 공개 페이지 섹션의 `px-*`를 제거하고 레이아웃 그리드 패딩만 사용하도록 통일해 2중 패딩을 해소.
|
||||||
|
|
||||||
|
## v0.0.65
|
||||||
|
|
||||||
|
- 헤더 `/` 단축키·검색 영역 클릭으로 통합 검색 모달(`SiteSearchModal`)을 연다. `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable` 포커스일 때는 `/`를 가로채지 않는다.
|
||||||
|
- `GET /api/search?q=`와 `searchPublicContent`(저장소)로 태그·게시물(제목·요약·본문) 부분 일치 검색, 모달에서 Tags·Posts 섹션·일치 구간 강조·`html.site-search-open` 스크롤 잠금.
|
||||||
|
|
||||||
|
## v0.0.64
|
||||||
|
|
||||||
|
- 비개발용 `paths.mjs`가 `#internal/nitro`를 import하는데 루트 `package.json` `imports`에 없어 `Package import specifier "#internal/nitro" is not defined`가 나던 문제를, `scripts/node-paths-nitro-shim.mjs`로 최소 `useRuntimeConfig().app`만 제공하고 `#internal/nitro`를 매핑해 해결.
|
||||||
|
|
||||||
|
## v0.0.63
|
||||||
|
|
||||||
|
- `tailwindcss.cssPath`를 `~/assets/css/main.css`로 지정해, 없는 기본 경로 때문에 `node_modules/tailwindcss/tailwind.css`가 추가로 주입되던 이중 `@tailwind` 로딩을 제거.
|
||||||
|
- `tailwind.config.js` `content`에 `composables`·`modules`·`plugins` 스캔 경로 추가.
|
||||||
|
|
||||||
|
## v0.0.62
|
||||||
|
|
||||||
|
- 다크 인증 페이지에서 `input`이 UA 기본색으로 남아 글자·아이콘이 안 보이던 문제를 `main.css`의 `.auth-form-input`(글자색·캐럿·placeholder·autofill)로 보정.
|
||||||
|
- `AuthPasswordVisibilityToggle`은 Tailwind 의존을 줄이고 `scoped` CSS로 버튼·아이콘 크기·포커스 링을 고정.
|
||||||
|
- signin/signup 루트에 `[color-scheme:dark]`, signup 패널에 `border`·`bg-[#0d1116]`(`auth-signup__panel`)로 카드 대비 보강.
|
||||||
|
|
||||||
|
## v0.0.61
|
||||||
|
|
||||||
|
- 로그인·회원가입 비밀번호 표시 토글을 `AuthPasswordVisibilityToggle`(Material 스타일 SVG 눈 아이콘)으로 통일, 텍스트 보기/숨기기 제거.
|
||||||
|
- 회원가입 비밀번호·비밀번호 확인 각각 독립 토글·포커스 링을 가진 입력 행으로 정리.
|
||||||
|
|
||||||
|
## v0.0.60
|
||||||
|
|
||||||
|
- 홈 Featured 가로 트랙에 `touch-pan-x`·`-webkit-overflow-scrolling:touch`·`overscroll-x-contain`을 두어 모바일에서 손가락으로 가로 슬라이드(스크롤·스냅)가 잘 먹도록 함.
|
||||||
|
- Featured 이전/다음 화살표는 스크롤 시작·끝에 따라 `disabled`와 시각적 비활성 처리, `ResizeObserver`·`scroll`로 상태 동기화.
|
||||||
|
|
||||||
|
## v0.0.59
|
||||||
|
|
||||||
|
- Nuxt 3.21 SSR이 `#internal/nuxt/paths`를 외부 import로 두는데 `.nuxt/paths.mjs`가 기본적으로 디스크에 쓰이지 않아 Node가 루트 `package.json`에서 해석하지 못하던 오류 수정.
|
||||||
|
- `modules/nuxt-ssr-paths-write.mjs`에서 `paths.mjs` 템플릿에 `write: true`를 부여하고, 루트 `package.json` `imports`로 `./.nuxt/paths.mjs`를 매핑.
|
||||||
|
- `nuxt` 의존성을 `^3.21.2`로 올려 lockfile과 정렬.
|
||||||
|
|
||||||
|
## v0.0.58
|
||||||
|
|
||||||
|
- 공개 3열 그리드 중앙을 `minmax(0,1fr)`로 바꾸고 `lg:gap-x-4`·`xl:gap-x-5`를 두어 본문과 오른쪽 사이드 사이 시각적 간격 확보.
|
||||||
|
- `site-main`의 고정 `lg:w-[720px]`를 제거하고 `lg:max-w-[720px]`·`justify-self-start`로 그리드 패딩·`gap`이 있어도 가로 넘침이 나지 않게 함.
|
||||||
|
- 좌측 메뉴 닫힘 시 `gap` 제거(`lg:!gap-x-0`)와 본문 `lg:pr-4`로 우측만 호흡 유지.
|
||||||
|
- `MainColumn`을 `lg:max-w-[720px]` 중심으로 정리.
|
||||||
|
|
||||||
|
## v0.0.57
|
||||||
|
|
||||||
|
- 왼쪽 사이드바 하단 푸터(`left-sidebar__footer`) 수평 패딩을 `px-1`에서 `px-4`·`sm:px-5`로 조정해 링크·테마 버튼이 가장자리에 붙지 않게 함.
|
||||||
|
- 오른쪽 사이드바 카피라이트 푸터에 `pr-3`을 두어 우측 여백을 맞춤.
|
||||||
|
|
||||||
|
## v0.0.56
|
||||||
|
|
||||||
|
- `lg`~`xl` 구간에서 헤더 내부 여백·열 간격을 키우고, 검색창을 고정 470px 대신 `max-w`+`flex-1`로 줄여 아이콘·버튼이 붙지 않게 보정.
|
||||||
|
- 브랜드 영역에 상한 `max-width`를 두어 좁은 데스크톱에서 제목과 검색·우측 액션이 겹치지 않게 함.
|
||||||
|
- 공개 레이아웃 그리드(`default`/`post`)에 `lg:px-5`·`xl:px-6`을 적용해 헤더와 본문 열 가장자리를 맞춤.
|
||||||
|
|
||||||
|
## v0.0.55
|
||||||
|
|
||||||
|
- 모바일(`lg` 미만)에서 좌측 사이드바를 고정 슬라이드 패널+백드롭으로 표시하고, 닫기는 `closeMenu`/백드롭/Escape로 처리.
|
||||||
|
- 모바일에서 본문 아래에 오른쪽 사이드바를 배치하고, 좁은 폭에서 헤더·우측 패널이 화면 가장자리에 붙지 않도록 여백·구독 폼 레이아웃을 보정.
|
||||||
|
- 좌측 메뉴 열림 시 모바일에서 문서 스크롤 잠금(`html.site-mobile-nav-open`) 적용.
|
||||||
|
- 데스크톱에서만 메뉴 닫힘 시 `max-width`가 줄어들도록 보정(`lg:max-w-[1007px]`).
|
||||||
|
|
||||||
|
## v0.0.54
|
||||||
|
|
||||||
|
- 회원가입 화면(`signup`)을 모바일 우선 여백·카드 패널 구조로 보정해 가독성 개선.
|
||||||
|
- 회원가입 2단계에 로그인 진입 링크 추가.
|
||||||
|
- 회원가입 3단계 상태 메시지(`aria-live`)와 재전송 안내 문구를 정리.
|
||||||
|
- 로그인 화면(`signin`) 비밀번호 보기/숨기기 토글 추가.
|
||||||
|
- 로그인 화면 상태 메시지를 오류/안내로 분리하고 홈 이동 링크 추가.
|
||||||
|
|
||||||
|
## v0.0.53
|
||||||
|
|
||||||
|
- 게시물 상세 제목 우측 공유 버튼에 공유 모달 추가(X/Bluesky/Facebook/LinkedIn/Email/링크복사).
|
||||||
|
- 헤더 우측 Account 링크 제거, 사용자 아바타 버튼+드롭다운(Anonymous/Sign up/Sign in) 추가.
|
||||||
|
- 회원가입 화면(`/signup`)을 AFFiNE 참고 3단계(환영/입력/이메일 확인) 플로우로 추가.
|
||||||
|
- 회원가입 3단계에 인증 메일 재전송(쿨다운)과 인증 완료 후 로그인 이동 흐름 추가.
|
||||||
|
- 로그인 화면(`/signin`)을 동일한 다크 톤 폼 스타일로 추가.
|
||||||
|
- 회원가입 하단 스텝 인디케이터를 `min-h` 기반 하단 고정 배치로 보정.
|
||||||
|
- 회원가입 1단계 문구를 사이트 설정(`site-settings.title`, `site-settings.description`) 연동으로 변경.
|
||||||
|
- 회원가입 페이지 타이머(`setInterval`)를 `onMounted`에서만 실행하도록 수정해 SSR 오류를 해결.
|
||||||
|
|
||||||
|
## v0.0.52
|
||||||
|
|
||||||
|
- 홈/태그 목록 Featured 아이콘이 제목 줄 높이를 흔들지 않도록 고정 크기(`h-4 w-4`)·정렬 방식으로 보정.
|
||||||
|
- 게시물 상세 제목 아래 메타 정보에 `/` 구분자 표시를 `after` 규칙으로 통일.
|
||||||
|
|
||||||
|
## v0.0.51
|
||||||
|
|
||||||
|
- 좌·우 사이드바 데스크톱 열 높이를 `calc(100vh - 57px)`로 고정해 내부 스크롤·하단 푸터 배치가 뷰포트 기준으로 맞도록 수정.
|
||||||
|
- 사이드 푸터에 상단 보더 추가(스크롤 영역과 시각적 구분).
|
||||||
|
- 공개 피드·게시 상세·아카이브 발행일 `YYYY.MM.DD` 통일, `composables/formatPostDate.js` 사용·`<time datetime>` 보강.
|
||||||
|
- `pages/tag/[slug].vue` 동일 날짜·datetime 처리.
|
||||||
|
|
||||||
|
## v0.0.50
|
||||||
|
|
||||||
|
- 데스크톱(`lg+`)에서 긴 본문은 **문서 스크롤**(브라우저 오른쪽 스크롤바)로 처리하고, `main` 단독 스크롤은 제거.
|
||||||
|
- 좌·우 사이드바는 `sticky`+`max-h`로 뷰포트에 맞추고, 내부 스크롤은 `.site-sidebar-scroll`로 스크롤바만 숨김(휠·트랙패드 스크롤 유지).
|
||||||
|
|
||||||
|
## v0.0.49
|
||||||
|
|
||||||
|
- 데스크톱(`lg+`) 공개 레이아웃을 뷰포트 높이에 고정하고 중앙 컬럼만 세로 스크롤되도록 변경.
|
||||||
|
- 좌·우 사이드바는 열 높이 안에서 본문만 스크롤하고 푸터(링크·테마·카피라이트)는 항상 열 하단에 고정.
|
||||||
|
|
||||||
|
## v0.0.48
|
||||||
|
|
||||||
|
- 공개 본문에 Thred형 북마크 카드(`ProseBookmark`, `:::bookmark`)와 회원가입 CTA(`ProseSignup`, `:::signup`) 추가.
|
||||||
|
- `ProseEmbed`에서 Twitter/X 게시물 URL을 공식 embed iframe으로 렌더링하고 테마와 동기화.
|
||||||
|
- Callout 왼쪽 강조선·이미지 캡션 변수 색상 등 카드/미디어 스타일을 Thred에 가깝게 보정.
|
||||||
|
|
||||||
|
## v0.0.47
|
||||||
|
|
||||||
|
- 공개 본문 렌더러에서 Ordered list(숫자 목록) 문법을 지원.
|
||||||
|
- 공개 본문 인용구를 멀티라인 처리하고 대체(Alternative) 인용구 스타일 문법을 추가.
|
||||||
|
- 공개 본문 리스트/인용/이미지/갤러리/카드류 기본 스타일을 Thred 톤(보더/라운드/패널/타이포)으로 통일.
|
||||||
|
- 기술 명세에 공개 본문 스타일 가이드를 추가.
|
||||||
|
|
||||||
|
## v0.0.46
|
||||||
|
|
||||||
|
- 홈 Latest 피드 보기 방식 토글(4종) 드롭다운과 localStorage 저장 추가.
|
||||||
|
- 홈 Latest 피드 Default 아이콘을 원본(restore) SVG로 보정.
|
||||||
|
- 홈/태그 상세 목록 Featured 번개 아이콘을 SVG로 교체.
|
||||||
|
- 홈/태그 상세 목록 댓글 아이콘을 SVG로 교체.
|
||||||
|
- Nuxt future 호환성 버전을 명시해 설정 resolve 오류를 예방.
|
||||||
|
- 개발 서버 실행 스크립트에서 종료 시 최근 로그를 함께 출력하도록 보강.
|
||||||
|
- 로컬 개발 환경 파일 감시(EMFILE) 이슈 완화를 위해 Vite watch polling 옵션 추가.
|
||||||
|
- 게시물 레이아웃에서 누락된 그리드 클래스를 복구해 `/post/:slug` 화면 깨짐을 수정.
|
||||||
|
- 게시물 상세 헤더를 Thred 패턴(제목+메타+썸네일) 기준으로 재구성.
|
||||||
|
- 태그 목록 카드의 ↗ 텍스트 아이콘을 SVG 아이콘으로 교체.
|
||||||
|
|
||||||
|
## v0.0.45
|
||||||
|
|
||||||
|
- 사용자 화면 기본 배경을 `#fcfcfc`로 통일하고 보더 기준 구분으로 정리.
|
||||||
|
- 오른쪽 사이드바 왼쪽 경계선이 항상 보이도록 보더 추가.
|
||||||
|
- 왼쪽 사이드바 열림/닫힘 시 폭이 스르륵 줄어드는 전환 애니메이션 추가.
|
||||||
|
- 왼쪽 네비게이션 항목 왼쪽에 회색 세로 바 표시를 추가하고 hover 시 원형 아이콘으로 전환되도록 수정.
|
||||||
|
- 왼쪽 네비게이션 hover 배경색을 더 연하게 조정하고, 마우스 오버 시 텍스트가 함께 이동하도록 전환 효과 보정.
|
||||||
|
- 왼쪽 사이드바 전환과 네비게이션 hover 효과 구현을 커스텀 CSS에서 Tailwind 유틸리티 클래스로 전환.
|
||||||
|
- 왼쪽 네비게이션과 카테고리 영역의 패딩, 간격, hover 동작을 원본 Thred 마크업 패턴에 맞춰 재정렬.
|
||||||
|
- 왼쪽 사이드바 네비게이션/카테고리/작성자 섹션의 내부 패딩과 텍스트 이동량을 미세 조정.
|
||||||
|
- 태그 목록 페이지를 원본 Thred 구조에 맞춰 중앙 히어로와 3열 태그 카드 레이아웃으로 재구성.
|
||||||
|
- 태그 상세 페이지(`tag/[slug]`)를 원본 Thred 구조에 맞춰 헤더 간격과 리스트형 게시물 카드 레이아웃으로 재구성.
|
||||||
|
- 태그 상세 페이지 게시물 메타 영역에 featured 강조, 태그 컬러 배지, 구분자 스타일을 원본 패턴에 맞춰 보정.
|
||||||
|
- 태그 상세 페이지에서 복수 태그 글은 첫 번째 태그만 배지로 표시하고, 배지와 `/` 구분자가 겹치지 않도록 메타 구조 수정.
|
||||||
|
- 오른쪽 사이드바 Follow 영역을 원본 패턴의 소셜 아이콘 링크 행으로 교체.
|
||||||
|
- 홈 중앙 메인 영역을 원본 Thred 구조에 맞춰 Hero/Featured/Latest 섹션 간격과 내부 보더 정렬 기준으로 재구성.
|
||||||
|
- 홈 Latest와 태그 상세 목록 카드 우하단 hover 화살표 액션 버튼 추가.
|
||||||
|
- 홈 Latest와 태그 상세 목록 카드의 우하단 액션 화살표를 텍스트에서 SVG 아이콘으로 교체.
|
||||||
|
- 홈 Featured 영역을 그리드에서 가로 슬라이드 트랙(좌우 버튼 이동) 구조로 변경.
|
||||||
|
- 홈 Featured 슬라이드 폭을 브레이크포인트별 1.4/1.6/2.6 카드 비율 계산식으로 보정.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.45로 갱신.
|
||||||
|
|
||||||
|
## v0.0.44
|
||||||
|
|
||||||
|
- 사용자 화면 다크/라이트 테마 전환 composable 추가.
|
||||||
|
- 헤더와 좌측 사이드바에 테마 전환 버튼 연결.
|
||||||
|
- 사용자 화면 링크, 카드, 입력, 버튼 hover 인터랙션 보강.
|
||||||
|
- `ZCF-v1.0.5` 및 샘플 폴더가 Git에 포함되지 않도록 제외 규칙 추가.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.44로 갱신.
|
||||||
|
|
||||||
|
## v0.0.43
|
||||||
|
|
||||||
|
- 대표 이미지가 설정된 상태의 변경/삭제 액션을 이미지 아래 버튼 영역이 아니라 이미지 hover 오버레이로 수정.
|
||||||
|
- 대표 이미지 선택 모달에서 미디어 클릭 시 즉시 적용하지 않고 선택 상태만 표시하도록 수정.
|
||||||
|
- 대표 이미지 선택 모달 하단의 대표 이미지로 적용 버튼으로 선택 이미지를 확정하도록 수정.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.43으로 갱신.
|
||||||
|
- 패키지 버전을 0.0.43으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.42
|
||||||
|
|
||||||
|
- 관리자 글쓰기 화면의 문서 스크롤 잠금 클래스를 html/body에 직접 적용하도록 보강.
|
||||||
|
- 관리자 글 설정의 태그 입력을 Figma 기준 배지형 입력으로 수정.
|
||||||
|
- 태그 입력 중 Enter가 게시물 저장으로 이어지던 문제 수정.
|
||||||
|
- 관리자 글 설정에서 Canonical URL과 OG 이미지 입력 UI 제거.
|
||||||
|
- 게시물 저장 시 Canonical URL은 기본 글 주소, OG 이미지는 대표 이미지를 따르도록 정리.
|
||||||
|
- 대표 이미지 선택 모달에 이미지 업로드와 미디어 라이브러리 탭 추가.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.42로 갱신.
|
||||||
|
- 패키지 버전을 0.0.42로 갱신.
|
||||||
|
|
||||||
|
## v0.0.41
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 `/` 명령 메뉴가 아래 블록 텍스트와 겹쳐 보이던 문제 수정.
|
||||||
|
- 관리자 블록 에디터 드래그 종료 시 삽입선 위치 기준으로 블록 이동이 확정되도록 보정.
|
||||||
|
- 개발 서버에서 Nuxt DevTools 도킹 패널로 보이는 하단 검은 영역을 막기 위해 DevTools 비활성화.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.41로 갱신.
|
||||||
|
- 패키지 버전을 0.0.41로 갱신.
|
||||||
|
|
||||||
|
## v0.0.40
|
||||||
|
|
||||||
|
- 관리자 글쓰기 화면에서 바깥 문서가 함께 스크롤되어 하단 배경이 노출되던 문제 수정.
|
||||||
|
- 관리자 블록 에디터 드래그 중 드롭 위치를 위/아래 삽입선으로 표시하도록 추가.
|
||||||
|
- 관리자 블록 에디터 드롭 이동 위치가 표시된 삽입선과 일치하도록 수정.
|
||||||
|
- 관리자 블록 에디터 한글 조합 중 Shift+Enter 입력 시 조합 완료 후 줄바꿈이 바로 적용되도록 보정.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.40으로 갱신.
|
||||||
|
- 패키지 버전을 0.0.40으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.39
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 Shift+Enter 줄바꿈이 문단 첫 위치로 커서를 이동시키던 문제 수정.
|
||||||
|
- 관리자 블록 에디터 텍스트 DOM 동기화를 `textContent` 기준으로 보정.
|
||||||
|
- 관리자 블록 에디터 왼쪽 블록 핸들을 AFFiNE 참고 스타일의 세로 막대형 hover 표시로 수정.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.39로 갱신.
|
||||||
|
- 패키지 버전을 0.0.39로 갱신.
|
||||||
|
|
||||||
|
## v0.0.38
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 슬래시 메뉴 필터가 입력 즉시 동기화되도록 보정.
|
||||||
|
- 관리자 블록 에디터에서 `/갤` Enter 입력 시 필터링된 갤러리 명령이 적용되도록 수정.
|
||||||
|
- 관리자 블록 에디터에서 Shift+Enter 줄바꿈과 Enter 문단 생성을 구분하도록 수정.
|
||||||
|
- 관리자 블록 에디터 구조형 블록의 자체 위아래 여백을 제거하고 블록 행 사이 margin-top 기준으로 정리.
|
||||||
|
- 관리자 블록 에디터 하단의 불필요한 최소 높이로 생기던 빈 영역 제거.
|
||||||
|
- 관리자 글 수정 화면의 보기/삭제 액션을 Figma 기준에 맞춰 게시물 설정 패널 내부로 이동.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.38로 갱신.
|
||||||
|
- 패키지 버전을 0.0.38로 갱신.
|
||||||
|
|
||||||
|
## v0.0.37
|
||||||
|
|
||||||
|
- 관리자 블록 에디터의 한글 조합 입력 종료 후 Enter 처리 보정.
|
||||||
|
- 관리자 블록 에디터의 슬래시 메뉴 필터가 한글 조합 완료 뒤 갱신되도록 수정.
|
||||||
|
- 관리자 블록 에디터에서 ` ``` ` 입력 시 코드 블록으로 즉시 변환되도록 추가.
|
||||||
|
- 관리자 코드 블록 글자색과 caret 색상 보정.
|
||||||
|
- 관리자 블록 에디터에 블록 핸들 선택, Delete/Backspace 삭제, 드래그 이동 기능 추가.
|
||||||
|
- 할 일 문서에 블록 핸들 액션 메뉴 후속 작업 추가.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.37로 갱신.
|
||||||
|
- 패키지 버전을 0.0.37로 갱신.
|
||||||
|
|
||||||
|
## v0.0.36
|
||||||
|
|
||||||
|
- 할 일 문서에서 완료 또는 현재 기준 불필요한 QA 항목 정리.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.36으로 갱신.
|
||||||
|
- 패키지 버전을 0.0.36으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.35
|
||||||
|
|
||||||
|
- 관리자 공통 레이아웃의 기본 내부 패딩 제거.
|
||||||
|
- 관리자 글 작성/수정 화면에서 좌측 관리자 네비게이션을 숨기도록 수정.
|
||||||
|
- 관리자 글 작성/수정 폼을 에디터 작업 영역과 우측 설정 패널의 1차 좌우 분할 구조로 수정.
|
||||||
|
- 게시물 설정 패널 열림/닫힘 너비 전환 애니메이션 추가.
|
||||||
|
- 관리자 글 작성/수정 화면의 버튼, 입력, 미디어 선택 요소 hover/focus 효과 보강.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.35로 갱신.
|
||||||
|
- 패키지 버전을 0.0.35로 갱신.
|
||||||
|
|
||||||
|
## v0.0.34
|
||||||
|
|
||||||
|
- 배포 문서에 개발/운영 DB 분리 검증 절차 추가.
|
||||||
|
- 로컬 개발 DB 실행 안내에 Docker Desktop과 Colima 기준 병기.
|
||||||
|
- 할 일에서 개발 DB와 운영 DB 분리 검증 절차 작성 항목 정리.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.34로 갱신.
|
||||||
|
- 패키지 버전을 0.0.34로 갱신.
|
||||||
|
|
||||||
|
## v0.0.33
|
||||||
|
|
||||||
|
- 개발 서버 실행 링크 출력을 개발 서버, Admin, Tailwind Viewer 3개 주소로 정리.
|
||||||
|
- 개발 서버 실행 링크가 Nuxt 출력 URL 대신 프로젝트 고정 포트 43117 기준으로 표시되도록 수정.
|
||||||
|
- 배포 문서에 로컬 PostgreSQL DB 확인 방법 추가.
|
||||||
|
- 할 일에서 CloudBeaver PostgreSQL 연결 방식 확정 항목 정리.
|
||||||
|
- 기술 명세 현재 버전을 v0.0.33으로 갱신.
|
||||||
|
- 패키지 버전을 0.0.33으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.32
|
||||||
|
|
||||||
|
- 관리자 글 작성/수정 화면의 별도 제목 헤더 제거.
|
||||||
|
- 관리자 글 작성/수정 폼을 Ghost 스타일 상단 바와 중앙 에디터 구조로 수정.
|
||||||
|
- 대표 이미지 추가 영역을 에디터 본문 상단으로 이동.
|
||||||
|
- 게시물 설정 패널을 420px 우측 패널과 토글 구조로 정리.
|
||||||
|
- 관리자 글 에디터 컬럼을 좌우 32px 패딩 포함 804px로 보정해 실제 본문 폭 740px 유지.
|
||||||
|
- 블록 에디터 첫 placeholder 문구를 간결하게 수정.
|
||||||
|
- 패키지 버전을 0.0.32로 갱신.
|
||||||
|
|
||||||
|
## v0.0.31
|
||||||
|
|
||||||
|
- 관리자 글 작성/수정 폼에 미리보기 버튼 추가.
|
||||||
|
- 저장 전 게시물 입력값을 브라우저 저장소에 담아 새 미리보기 탭으로 전달하도록 추가.
|
||||||
|
- 관리자 게시물 미리보기 화면 추가.
|
||||||
|
- 미리보기 화면이 공개 게시물 본문 렌더러와 같은 컴포넌트를 사용하도록 연결.
|
||||||
|
- 패키지 버전을 0.0.31로 갱신.
|
||||||
|
|
||||||
|
## v0.0.30
|
||||||
|
|
||||||
|
- 게시물 OG 이미지 필드 추가.
|
||||||
|
- 관리자 글 작성/수정 폼에 OG 이미지 선택, 업로드, 변경, 삭제 기능 추가.
|
||||||
|
- 공개 게시물 상세 화면에 OG 이미지와 Twitter large image 카드 메타 연결.
|
||||||
|
- 미디어 라이브러리 사용 현황에 게시물 OG 이미지 사용처 표시 추가.
|
||||||
|
- 패키지 버전을 0.0.30으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.29
|
||||||
|
|
||||||
|
- 게시물 SEO 메타데이터 컬럼 추가.
|
||||||
|
- 관리자 글 작성/수정 폼에 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 설정 추가.
|
||||||
|
- 관리자 게시물 생성/수정 API가 SEO 값을 저장하도록 수정.
|
||||||
|
- 공개 게시물 상세 화면에 SEO 제목, description, canonical, robots, 기본 OG 메타 연결.
|
||||||
|
- 패키지 버전을 0.0.29로 갱신.
|
||||||
|
|
||||||
|
## v0.0.28
|
||||||
|
|
||||||
|
- 관리자 글 작성/수정 폼에 발행 시각 입력 기능 추가.
|
||||||
|
- 발행 상태에서 미래 발행 시각을 저장하면 예약 발행으로 처리하도록 추가.
|
||||||
|
- 공개 게시물 목록과 상세 API가 미래 발행 글을 노출하지 않도록 수정.
|
||||||
|
- 관리자 글 목록에서 발행/예약/초안/비공개 상태 표시를 구분하도록 수정.
|
||||||
|
- 예약 발행 글은 공개 보기 버튼을 숨기도록 수정.
|
||||||
|
- 패키지 버전을 0.0.28로 갱신.
|
||||||
|
|
||||||
|
## v0.0.27
|
||||||
|
|
||||||
|
- 미디어 폴더 테이블 추가.
|
||||||
|
- 관리자 미디어 폴더 목록/생성 API 추가.
|
||||||
|
- 관리자 미디어 화면을 왼쪽 폴더 트리와 오른쪽 썸네일 갤러리 구조로 수정.
|
||||||
|
- 미디어 Ctrl/Command 클릭 및 Shift 클릭 복수 선택 기능 추가.
|
||||||
|
- 선택 미디어를 폴더로 드래그해 일괄 이동하는 기능 추가.
|
||||||
|
- 미디어 폴더 이동은 실제 파일 경로가 아닌 메타데이터 경로를 갱신하도록 유지.
|
||||||
|
- 패키지 버전을 0.0.27로 갱신.
|
||||||
|
|
||||||
|
## v0.0.26
|
||||||
|
|
||||||
|
- 미디어 메타데이터 테이블 추가.
|
||||||
|
- 미디어 URL별 카테고리 저장 기능 추가.
|
||||||
|
- 관리자 미디어 목록에 카테고리 필터 추가.
|
||||||
|
- 관리자 미디어 상세 모달에 카테고리 수정 기능 추가.
|
||||||
|
- 미디어 파일명 변경/삭제 시 메타데이터도 함께 갱신하도록 수정.
|
||||||
|
- 패키지 버전을 0.0.26으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.25
|
||||||
|
|
||||||
|
- 관리자 블록 에디터에서 빈 문단 placeholder를 마지막 보조 입력 블록에만 표시하도록 수정.
|
||||||
|
- 네비게이션 항목 데이터베이스 테이블 추가.
|
||||||
|
- 공개 네비게이션 조회 API 추가.
|
||||||
|
- 관리자 네비게이션 조회/일괄 저장 API 추가.
|
||||||
|
- 관리자 메뉴 관리 화면 추가.
|
||||||
|
- 공개 왼쪽 사이드바 상단/하단 메뉴를 네비게이션 API와 연결.
|
||||||
|
- 패키지 버전을 0.0.25로 갱신.
|
||||||
|
|
||||||
|
## v0.0.24
|
||||||
|
|
||||||
|
- 관리자 블록 에디터에서 마지막 빈 문단 Enter 입력 시 연속 빈 줄이 유지되도록 수정.
|
||||||
|
- 사이트 설정 데이터베이스 테이블 추가.
|
||||||
|
- 공개 사이트 설정 조회 API 추가.
|
||||||
|
- 관리자 사이트 설정 조회/수정 API 추가.
|
||||||
|
- 관리자 사이트 설정 화면을 실제 저장 API와 연결.
|
||||||
|
- 공개 헤더와 오른쪽 사이드바에 사이트 설정 값을 연결.
|
||||||
|
- 패키지 버전을 0.0.24로 갱신.
|
||||||
|
|
||||||
|
## v0.0.23
|
||||||
|
|
||||||
|
- 관리자 고정 페이지 목록 화면을 실제 API와 연결.
|
||||||
|
- 관리자 고정 페이지 생성 화면 추가.
|
||||||
|
- 관리자 고정 페이지 수정 화면 추가.
|
||||||
|
- 관리자 고정 페이지 생성/수정/삭제 API 추가.
|
||||||
|
- 고정 페이지 작성 폼에 블록 에디터와 대표 이미지 선택 기능 연결.
|
||||||
|
- 패키지 버전을 0.0.23으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.22
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 마지막에 클릭 가능한 빈 문단 블록을 항상 유지하도록 수정.
|
||||||
|
- 빈 마지막 문단 블록은 저장 콘텐츠에 포함하지 않도록 유지.
|
||||||
|
- 한글 조합 입력 직후 Enter가 새 블록 입력으로 중복 처리되는 문제 보정.
|
||||||
|
- 관리자 글 저장/수정/삭제 진행 상태를 토스트로 표시하도록 추가.
|
||||||
|
- 새 글 저장 후 수정 화면으로 이동해도 저장 성공 토스트를 표시하도록 추가.
|
||||||
|
- 패키지 버전을 0.0.22로 갱신.
|
||||||
|
|
||||||
|
## v0.0.21
|
||||||
|
|
||||||
|
- 관리자 글 작성/수정 폼에 로컬 자동 저장 기능 추가.
|
||||||
|
- 자동 저장본이 있으면 복원 또는 삭제 안내를 표시하도록 추가.
|
||||||
|
- 글 저장 성공 시 해당 자동 저장본을 삭제하도록 연결.
|
||||||
|
- 자동 저장 상태 시간을 글 작성 화면 하단에 표시하도록 추가.
|
||||||
|
- 패키지 버전을 0.0.21로 갱신.
|
||||||
|
|
||||||
|
## v0.0.20
|
||||||
|
|
||||||
|
- 관리자 블록 에디터에 콜아웃 블록 추가.
|
||||||
|
- 관리자 블록 에디터에 토글 블록 추가.
|
||||||
|
- 관리자 블록 에디터에 임베드 블록 추가.
|
||||||
|
- 공개 본문 렌더러에 콜아웃, 토글, 임베드 마크다운 확장 파싱 추가.
|
||||||
|
- YouTube 임베드 URL을 공개 화면에서 iframe으로 렌더링하도록 수정.
|
||||||
|
- 패키지 버전을 0.0.20으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.19
|
||||||
|
|
||||||
|
- 관리자 블록 에디터의 한글 조합 입력 중복 방지 처리 추가.
|
||||||
|
- 이미지/갤러리 삽입 시 파일명을 alt/caption으로 자동 표시하지 않도록 수정.
|
||||||
|
- 이미지 블록 alt 입력을 이미지 hover 또는 focus 상태에서만 표시하도록 수정.
|
||||||
|
- 공개 게시물 카드에 대표 이미지 썸네일 표시 추가.
|
||||||
|
- ProseImage Wide/Full-width 화면 이탈 보정 작업을 할 일에 기록.
|
||||||
|
- 패키지 버전을 0.0.19로 갱신.
|
||||||
|
|
||||||
|
## v0.0.18
|
||||||
|
|
||||||
|
- 새 글 작성 화면에서 빈 본문 블록이 생성되지 않던 문제 수정.
|
||||||
|
- 공개 게시물 목록 경로 `/posts` 추가.
|
||||||
|
- 공개 게시물 상세 경로를 `/post/:slug` 기준으로 추가.
|
||||||
|
- 기존 `/posts/:slug` 상세 경로를 `/post/:slug`로 리다이렉트하도록 수정.
|
||||||
|
- 공개 태그 목록 경로 `/tags` 추가.
|
||||||
|
- 공개 태그 상세 경로를 `/tag/:slug` 기준으로 추가.
|
||||||
|
- 기존 `/tags/:slug` 상세 경로를 `/tag/:slug`로 리다이렉트하도록 수정.
|
||||||
|
- 공개 화면과 관리자 미리보기 링크를 단수형 상세 경로 기준으로 정리.
|
||||||
|
- 패키지 버전을 0.0.18로 갱신.
|
||||||
|
|
||||||
|
## v0.0.17
|
||||||
|
|
||||||
|
- 관리자 글 작성/수정 폼의 대표 이미지 URL 직접 입력을 이미지 선택 UI로 변경.
|
||||||
|
- 대표 이미지 썸네일, 삭제, 변경, 새 업로드 기능 추가.
|
||||||
|
- 대표 이미지를 기존 미디어 라이브러리에서 선택할 수 있도록 추가.
|
||||||
|
- 관리자 미디어 화면을 고밀도 썸네일 갤러리 구조로 변경.
|
||||||
|
- 미디어 경로, 사용 현황, 용량, 파일명 변경, 삭제 정보를 상세 모달로 이동.
|
||||||
|
- 패키지 버전을 0.0.17로 갱신.
|
||||||
|
|
||||||
|
## v0.0.16
|
||||||
|
|
||||||
|
- 관리자 미디어 목록에 게시물/페이지 사용 현황 표시 추가.
|
||||||
|
- 미디어 사용처를 대표 이미지와 본문 위치로 구분해 표시.
|
||||||
|
- 사용 중인 미디어의 파일명 변경과 삭제를 차단하도록 수정.
|
||||||
|
- 미디어 검색 대상에 사용 중인 게시물/페이지 제목 추가.
|
||||||
|
- 패키지 버전을 0.0.16으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.15
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 `/` 메뉴 항목 제목 색상을 진한 본문색으로 수정.
|
||||||
|
- 관리자 미디어 목록 API 추가.
|
||||||
|
- 관리자 미디어 파일명 변경 API 추가.
|
||||||
|
- 관리자 미디어 삭제 API 추가.
|
||||||
|
- 관리자 미디어 관리 화면 추가.
|
||||||
|
- 관리자 사이드바에 미디어 메뉴 추가.
|
||||||
|
- 글쓰기 이미지/갤러리 블록에서 기존 업로드 미디어 선택 기능 추가.
|
||||||
|
- 패키지 버전을 0.0.15로 갱신.
|
||||||
|
|
||||||
|
## v0.0.14
|
||||||
|
|
||||||
|
- 관리자 블록 에디터에 단일 이미지 블록 추가.
|
||||||
|
- 관리자 블록 에디터에 복수 이미지 갤러리 블록 추가.
|
||||||
|
- 이미지 블록의 기본/와이드/풀사이즈 표시 옵션 추가.
|
||||||
|
- 관리자 이미지 업로드 API 추가.
|
||||||
|
- 공개 본문 렌더러에 이미지와 갤러리 렌더링 추가.
|
||||||
|
- 공개 갤러리 이미지 클릭 시 라이트박스로 크게 보는 기능 추가.
|
||||||
|
- 업로드 파일이 Git에 포함되지 않도록 `public/uploads/` 제외 처리.
|
||||||
|
- 향후 미디어 라이브러리 관리 기능 범위 정리.
|
||||||
|
- 패키지 버전을 0.0.14로 갱신.
|
||||||
|
|
||||||
|
## v0.0.13
|
||||||
|
|
||||||
|
- 개발 서버 실행 로그를 프로젝트 전용 링크 요약 출력으로 정리.
|
||||||
|
- `npm run dev`가 `scripts/dev-server.js`를 통해 Nuxt 개발 서버를 실행하도록 수정.
|
||||||
|
- 패키지 버전을 0.0.13으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.12
|
||||||
|
|
||||||
|
- 관리자 글 제목 입력에서 Enter 입력 시 본문 첫 블록으로 이동하도록 수정.
|
||||||
|
- 관리자 블록 에디터 본문 글자 색상을 관리자 화면 기준으로 고정.
|
||||||
|
- 패키지 버전을 0.0.12로 갱신.
|
||||||
|
|
||||||
|
## v0.0.11
|
||||||
|
|
||||||
|
- 관리자 블록 에디터에서 빈 문단 Enter 입력 시 새 빈 블록이 생성되도록 수정.
|
||||||
|
- 관리자 블록 에디터의 `/` 명령 메뉴에서 Enter로 선택 항목을 적용하도록 수정.
|
||||||
|
- 관리자 블록 에디터의 `/` 명령 메뉴에 위/아래 방향키 선택 이동 추가.
|
||||||
|
- 관리자 글 에디터 후속 작업 순서 정리.
|
||||||
|
- 패키지 버전을 0.0.11로 갱신.
|
||||||
|
|
||||||
|
## v0.0.10
|
||||||
|
|
||||||
|
- 관리자 블록 에디터의 `contenteditable` 입력 중복 문제 수정.
|
||||||
|
- 관리자 블록 에디터의 `/` 명령 메뉴가 화면 하단에서 위로 열리도록 수정.
|
||||||
|
- 빈 블록 placeholder가 활성 블록에만 표시되도록 수정.
|
||||||
|
- 관리자 글 제목 입력을 본문 흐름 안의 큰 제목 필드로 변경.
|
||||||
|
- 패키지 버전을 0.0.10으로 갱신.
|
||||||
|
|
||||||
|
## v0.0.9
|
||||||
|
|
||||||
|
- 관리자 글 작성/수정 폼을 textarea 방식에서 블록형 에디터 방식으로 변경.
|
||||||
|
- 관리자 블록 에디터에 `/` 명령 메뉴 추가.
|
||||||
|
- 관리자 블록 에디터에 `#`, `##`, `###`, `>`, `-` 입력 단축 변환 추가.
|
||||||
|
- 공개 게시물과 고정 페이지 본문을 마크다운 렌더러에 연결.
|
||||||
|
- 패키지 버전을 0.0.9로 갱신.
|
||||||
|
|
||||||
|
## v0.0.8
|
||||||
|
|
||||||
|
- 관리자 글 작성/수정 폼에 마크다운 미리보기 탭 추가.
|
||||||
|
- 관리자 글 작성/수정 폼에 제목, 굵게, 목록, 인용, 코드 문법 삽입 버튼 추가.
|
||||||
|
- 패키지 버전을 0.0.8로 갱신.
|
||||||
|
|
||||||
|
## v0.0.7
|
||||||
|
|
||||||
|
- 태그 정렬 순서와 색상 코드 필드 추가.
|
||||||
|
- 태그 표시 필드 마이그레이션 추가.
|
||||||
|
- 관리자 태그 생성/수정 화면을 개별 페이지로 분리.
|
||||||
|
- 관리자 태그 목록 화면의 인라인 수정 제거.
|
||||||
|
- 공개 좌측 사이드바 카테고리를 실제 태그 색상과 정렬 순서 기준으로 연결.
|
||||||
|
- 관리자 태그 상세 조회 API 추가.
|
||||||
|
- 관리자 태그 목록 API 추가.
|
||||||
|
- 관리자 태그 생성 API 추가.
|
||||||
|
- 관리자 태그 수정 API 추가.
|
||||||
|
- 관리자 태그 삭제 API 추가.
|
||||||
|
- 관리자 태그 관리 화면을 실제 API에 연결.
|
||||||
|
- 관리자 글 삭제 API 추가.
|
||||||
|
- 관리자 글 목록과 수정 화면에 삭제 액션 추가.
|
||||||
|
- 관리자 글 작성 API 추가.
|
||||||
|
- 관리자 글 상세 조회 API 추가.
|
||||||
|
- 관리자 글 수정 API 추가.
|
||||||
|
- 관리자 글 작성/수정 공통 폼 추가.
|
||||||
|
- 관리자 새 글 작성 화면과 수정 화면을 실제 저장 API에 연결.
|
||||||
|
- 관리자 글 상태를 초안/발행/비공개로 저장할 수 있도록 수정.
|
||||||
|
- 관리자 접근 미들웨어의 서버 인증 확인 방식 수정.
|
||||||
|
- 환경 변수 기반 관리자 로그인 기능 추가.
|
||||||
|
- 관리자 세션 쿠키 인증 유틸리티 추가.
|
||||||
|
- 관리자 로그아웃 및 세션 조회 API 추가.
|
||||||
|
- 관리자 글 목록 API와 화면 연결.
|
||||||
|
- 개발 서버의 `#app-manifest` 가상 모듈 분석 오류를 피하도록 Nuxt app manifest 실험 기능 비활성화.
|
||||||
|
- Nuxt 컴포넌트 자동 import 설정을 문서의 컴포넌트명 기준에 맞게 수정.
|
||||||
|
- 홈, 태그, 게시물, 고정 페이지 공개 화면을 실제 API 데이터에 연결.
|
||||||
|
- 로컬 PostgreSQL 마이그레이션 실행 스크립트 추가.
|
||||||
|
- 개발 DB 마이그레이션 npm 명령 추가.
|
||||||
|
- Homebrew, Docker CLI, Docker Compose, Colima 기반 로컬 컨테이너 실행 환경 구성.
|
||||||
|
- Docker Compose가 `ENV_FILE` 값으로 로컬/운영 환경 파일을 선택할 수 있도록 수정.
|
||||||
|
- 로컬 PostgreSQL 개발 DB 컨테이너 실행 및 시드 데이터 확인.
|
||||||
|
- Nuxt 개발/프리뷰 스크립트가 `.env.development`를 명시적으로 읽도록 수정.
|
||||||
|
- 새 개발 환경에서 Node.js 22 LTS 기준 의존성 설치 및 빌드 검증.
|
||||||
|
- 로컬 개발 필수 조건 문서의 Node.js 권장 버전 정리.
|
||||||
|
- 패키지 버전을 0.0.7로 갱신.
|
||||||
|
|
||||||
|
## v0.0.6
|
||||||
|
|
||||||
|
- `.env.example`의 실제 계정/비밀번호 값을 예시 전용 placeholder로 교체.
|
||||||
|
- 로컬 개발 전용 `.env.development` 파일 생성.
|
||||||
|
- 개발 DB 비밀번호와 관리자 비밀번호를 랜덤 값으로 분리.
|
||||||
|
- 환경 변수 파일 관리 기준 문서 정리.
|
||||||
|
- 패키지 버전을 0.0.6으로 갱신.
|
||||||
|
- 이미 원격에 올라간 비밀번호 사용 여부 점검 항목 추가.
|
||||||
|
|
||||||
|
## v0.0.5
|
||||||
|
|
||||||
|
- PostgreSQL 초기 스키마 마이그레이션 추가.
|
||||||
|
- 개발용 시드 데이터 SQL 추가.
|
||||||
|
- Nuxt 서버 API 저장소 계층 추가.
|
||||||
|
- `DATABASE_URL`이 있으면 PostgreSQL을 사용하고, 없으면 샘플 데이터를 사용하도록 수정.
|
||||||
|
- Docker Compose에 PostgreSQL 서비스와 전용 DB 포트 43119 추가.
|
||||||
|
- 공개 API가 저장소 계층을 통해 게시물, 페이지, 태그를 조회하도록 수정.
|
||||||
|
|
||||||
|
## v0.0.4
|
||||||
|
|
||||||
|
- 헤더 좌측 아이콘을 브랜드 마크에서 메뉴 토글 버튼으로 수정.
|
||||||
|
- Vue/Nuxt 상태 기반 좌측 사이드바 열기/닫기 기능 추가.
|
||||||
|
- 메뉴 열림 상태를 `localStorage`의 `MENU_STATE`에 저장하도록 추가.
|
||||||
|
- 메뉴 닫힘 상태에서 공개 레이아웃 그리드가 좌측 사이드바 폭을 제거하도록 수정.
|
||||||
|
|
||||||
|
## v0.0.3
|
||||||
|
|
||||||
|
- Thred 참고 화면 기준 공개 레이아웃 색상 토큰 정리.
|
||||||
|
- 라이트/다크 모드 CSS 변수 기반 테마 추가.
|
||||||
|
- 헤더 아래 3단 컬럼 최소 높이를 화면 전체 높이로 수정.
|
||||||
|
- 좌우 사이드바를 본문과 별개로 전체 높이 패널처럼 표시하도록 수정.
|
||||||
|
- 홈 화면 히어로, 추천 영역, 최신 글 영역 구조 보강.
|
||||||
|
- 사이트 헤더 검색 영역과 구독/계정 액션 구조 추가.
|
||||||
|
- 좌우 사이드바 임시 콘텐츠 구조 보강.
|
||||||
|
- 로컬 개발/프리뷰 포트 43117 유지.
|
||||||
|
|
||||||
|
## v0.0.2
|
||||||
|
|
||||||
|
- Nuxt 3 프로젝트 기본 실행 구조 추가.
|
||||||
|
- Tailwind CSS 설정과 전역 Pretendard 기준 스타일 추가.
|
||||||
|
- 공개 레이아웃, 게시물 레이아웃, 고정 페이지 레이아웃, 관리자 레이아웃 골격 추가.
|
||||||
|
- 사이트 컴포넌트와 콘텐츠 컴포넌트 초기 골격 추가.
|
||||||
|
- 홈, 게시물 상세, 태그, 고정 페이지, 관리자 기본 화면 추가.
|
||||||
|
- Nuxt `server/api` 기반 백엔드 골격 추가.
|
||||||
|
- Zod 기반 콘텐츠 스키마와 샘플 API 추가.
|
||||||
|
- `.env.example`, Dockerfile, docker-compose.yml, .dockerignore 추가.
|
||||||
|
- 로컬 개발 포트 43117, NAS Docker 외부 포트 43118 기준 추가.
|
||||||
|
|
||||||
## v0.0.1
|
## v0.0.1
|
||||||
|
|
||||||
- sori.studio 개인 블로그/CMS 방향 정리.
|
- sori.studio 개인 블로그/CMS 방향 정리.
|
||||||
|
|||||||
272
layouts/admin.vue
Normal file
272
layouts/admin.vue
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<script setup>
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const isPostEditorRoute = computed(() => route.path === '/admin/posts/new'
|
||||||
|
|| (route.path.startsWith('/admin/posts/') && route.path !== '/admin/posts/preview'))
|
||||||
|
|
||||||
|
const editorDocumentClass = 'admin-post-editor-document'
|
||||||
|
const adminUserMenuOpen = ref(false)
|
||||||
|
|
||||||
|
const { data: adminMember } = await useFetch('/api/auth/me', {
|
||||||
|
default: () => ({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
avatarUrl: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: adminMembers } = await useFetch('/admin/api/members', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberCount = computed(() => adminMembers.value.length)
|
||||||
|
const adminDisplayName = computed(() => adminMember.value?.username || adminMember.value?.email || '관리자')
|
||||||
|
const adminDisplayEmail = computed(() => adminMember.value?.email || '')
|
||||||
|
const adminAvatarUrl = computed(() => adminMember.value?.avatarUrl || '')
|
||||||
|
const adminAvatarInitial = computed(() => adminDisplayName.value.slice(0, 1).toUpperCase() || 'A')
|
||||||
|
const adminProfilePath = computed(() => adminMember.value?.id ? `/admin/members/${adminMember.value.id}` : '/admin/members')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 내비게이션 활성 경로 확인
|
||||||
|
* @param {string} path - 확인할 경로
|
||||||
|
* @returns {boolean} 활성 여부
|
||||||
|
*/
|
||||||
|
const isAdminNavActive = (path) => route.path === path || route.path.startsWith(`${path}/`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 사용자 메뉴 닫기
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeAdminUserMenu = () => {
|
||||||
|
adminUserMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 사용자 메뉴 토글
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleAdminUserMenu = () => {
|
||||||
|
adminUserMenuOpen.value = !adminUserMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 클릭 시 관리자 사용자 메뉴 닫기
|
||||||
|
* @param {PointerEvent} event - 포인터 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onAdminDocumentPointerDown = (event) => {
|
||||||
|
if (!adminUserMenuOpen.value || !(event.target instanceof HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('[data-admin-user-menu]')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAdminUserMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 글쓰기 전체 화면 문서 스크롤 잠금 적용
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const syncPostEditorDocumentClass = () => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
||||||
|
document.body.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(syncPostEditorDocumentClass)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointerdown', onAdminDocumentPointerDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.classList.remove(editorDocumentClass)
|
||||||
|
document.body.classList.remove(editorDocumentClass)
|
||||||
|
document.removeEventListener('pointerdown', onAdminDocumentPointerDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 로그아웃
|
||||||
|
* @returns {Promise<void>} 로그아웃 처리 결과
|
||||||
|
*/
|
||||||
|
const logoutAdmin = async () => {
|
||||||
|
await $fetch('/admin/api/auth/logout', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
closeAdminUserMenu()
|
||||||
|
await navigateTo('/admin/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="admin-layout bg-[#f7f8fa] text-ink"
|
||||||
|
:class="isPostEditorRoute ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
v-if="!isPostEditorRoute"
|
||||||
|
class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-80 flex-col border-r border-[#e6e8eb] bg-[#f7f8fa] px-5 py-6 text-[#15171a] lg:flex"
|
||||||
|
>
|
||||||
|
<NuxtLink class="admin-layout__brand flex items-center gap-3 px-2 text-[0.95rem] font-semibold tracking-[-0.01em]" to="/admin">
|
||||||
|
<span class="admin-layout__brand-mark flex h-8 w-8 items-center justify-center rounded-full border border-[#d8dce1] bg-white shadow-[0_1px_2px_rgba(15,23,42,0.05)]">
|
||||||
|
<span class="h-5 w-5 rounded-full border-2 border-[#15171a]" />
|
||||||
|
</span>
|
||||||
|
<span>sori.studio</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<nav class="admin-layout__nav mt-10 grid gap-1.5 text-sm font-medium text-[#5d6673]">
|
||||||
|
<div
|
||||||
|
class="admin-layout__nav-item group flex items-center rounded-md transition-colors"
|
||||||
|
:class="isAdminNavActive('/admin/posts') ? 'bg-[#e9ecef] text-[#15171a]' : 'hover:bg-[#eceff2] hover:text-[#15171a]'"
|
||||||
|
>
|
||||||
|
<NuxtLink class="admin-layout__nav-link flex min-w-0 flex-1 items-center gap-3 px-3 py-2" to="/admin/posts">
|
||||||
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.667 14.136l-3.712.531.53-3.713 9.546-9.546a2.25 2.25 0 013.182 3.182z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M19.122 14.25v7.5a1.5 1.5 0 01-1.5 1.5h-15a1.5 1.5 0 01-1.5-1.5v-15a1.5 1.5 0 011.5-1.5h7.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">게시글</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="admin-layout__nav-create mr-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[#5d6673] transition-colors hover:bg-white hover:text-[#15171a] hover:shadow-[0_1px_2px_rgba(15,23,42,0.08)]"
|
||||||
|
to="/admin/posts/new"
|
||||||
|
aria-label="새 게시글 작성"
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M8 1v14M1 8h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||||
|
:class="isAdminNavActive('/admin/pages') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||||
|
to="/admin/pages"
|
||||||
|
>
|
||||||
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M16.5 21.513a1.5 1.5 0 01-1.9 1.446L4.1 20.042A1.5 1.5 0 013 18.6V2.487a1.5 1.5 0 011.9-1.446l10.5 3.391a1.5 1.5 0 011.1 1.445z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M4.5.987h15a1.5 1.5 0 011.5 1.5v15.75a1.5 1.5 0 01-1.5 1.5h-3" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span>페이지</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||||
|
:class="isAdminNavActive('/admin/tags') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||||
|
to="/admin/tags"
|
||||||
|
>
|
||||||
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M1.061 2.56v6.257a3 3 0 00.878 2.121L13.5 22.5a1.5 1.5 0 002.121 0l6.879-6.88a1.5 1.5 0 000-2.121L10.939 1.938a3 3 0 00-2.121-.878H2.561a1.5 1.5 0 00-1.5 1.5z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span>태그</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||||
|
:class="isAdminNavActive('/admin/media') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||||
|
to="/admin/media"
|
||||||
|
>
|
||||||
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M305-193.85v-191.54l153.46 95.77L305-193.85Zm215-373.84q-47.44 0-80.64-33.18-33.21-33.18-33.21-80.58t33.21-80.67q33.2-33.26 80.64-33.26h43.85v47.69H520q-27.56 0-46.86 19.32-19.29 19.32-19.29 46.92t19.29 46.83q19.3 19.24 46.86 19.24h43.85v47.69H520Zm116.15 0v-47.69H680q27.56 0 46.86-19.33 19.29-19.32 19.29-46.92t-19.29-46.83q-19.3-19.23-46.86-19.23h-43.85v-47.69H680q47.44 0 80.64 33.17 33.21 33.18 33.21 80.58t-33.21 80.67q-33.2 33.27-80.64 33.27h-43.85Zm-110-90v-47.69h147.7v47.69h-147.7Zm106.7 239.61v-60h194.84q5.39 0 8.85-3.46t3.46-8.85v-335.38q0-5.38-3.46-8.84-3.46-3.47-8.85-3.47H372.31q-5.39 0-8.85 3.47-3.46 3.46-3.46 8.84v336.15h-60v-336.15q0-29.82 21.24-51.07 21.24-21.24 51.07-21.24h455.38q29.83 0 51.07 21.24Q900-855.59 900-825.77v335.38q0 29.83-21.24 51.07-21.24 21.24-51.07 21.24H632.85ZM132.31-61.92q-29.83 0-51.07-21.24Q60-104.41 60-134.23V-445q0-29.83 21.24-51.07 21.24-21.24 51.07-21.24h455.38q29.83 0 51.07 21.24Q660-474.83 660-445v310.77q0 29.82-21.24 51.07-21.24 21.24-51.07 21.24H132.31Zm0-60h455.38q5.39 0 8.85-3.47 3.46-3.46 3.46-8.84V-445q0-5.39-3.46-8.85t-8.85-3.46H132.31q-5.39 0-8.85 3.46T120-445v310.77q0 5.38 3.46 8.84 3.46 3.47 8.85 3.47ZM600-658.08ZM360-289.62Z" />
|
||||||
|
</svg>
|
||||||
|
<span>미디어</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||||
|
:class="isAdminNavActive('/admin/navigation') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||||
|
to="/admin/navigation"
|
||||||
|
>
|
||||||
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="-0.75 -0.75 24 24" aria-hidden="true">
|
||||||
|
<path d="M2.109375 0.7059375h18.28125s1.40625 0 1.40625 1.40625v18.28125s0 1.40625 -1.40625 1.40625H2.109375s-1.40625 0 -1.40625 -1.40625v-18.28125s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m6.328125 7.0340625 9.84375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m6.328125 11.252812500000001 9.84375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m6.328125 15.471562500000001 9.84375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span>네비게이션</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||||
|
:class="isAdminNavActive('/admin/members') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||||
|
to="/admin/members"
|
||||||
|
>
|
||||||
|
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="7.5" cy="7.875" r="4.125" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M.75 20.25a6.75 6.75 0 0113.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<circle cx="17.727" cy="10.125" r="3.375" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M15.813 15.068a5.526 5.526 0 017.437 5.182" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span class="min-w-0 flex-1">멤버</span>
|
||||||
|
<span class="admin-layout__member-count ml-auto rounded-full bg-[#e1e5e9] px-2 py-0.5 text-xs font-semibold text-[#5d6673]">
|
||||||
|
{{ memberCount }}
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
<div class="admin-layout__bottom relative mt-auto pt-6" data-admin-user-menu>
|
||||||
|
<div
|
||||||
|
v-if="adminUserMenuOpen"
|
||||||
|
class="admin-layout__user-popover absolute bottom-14 left-0 right-0 overflow-hidden rounded-xl border border-[#e2e5e9] bg-white text-[#15171a] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
|
||||||
|
>
|
||||||
|
<div class="admin-layout__user-summary flex items-center gap-3 border-b border-[#eceff2] px-4 py-4">
|
||||||
|
<img
|
||||||
|
v-if="adminAvatarUrl"
|
||||||
|
class="admin-layout__user-avatar h-11 w-11 rounded-full border border-[#e2e5e9] object-cover"
|
||||||
|
:src="adminAvatarUrl"
|
||||||
|
:alt="adminDisplayName"
|
||||||
|
>
|
||||||
|
<span v-else class="admin-layout__user-avatar flex h-11 w-11 items-center justify-center rounded-full border border-[#e2e5e9] bg-[#15171a] text-sm font-semibold text-white">
|
||||||
|
{{ adminAvatarInitial }}
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate text-sm font-semibold">{{ adminDisplayName }}</span>
|
||||||
|
<span class="block truncate text-xs text-[#657080]">{{ adminDisplayEmail }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-layout__user-actions grid py-2 text-sm text-[#3f4650]">
|
||||||
|
<NuxtLink class="admin-layout__user-action px-4 py-2.5 hover:bg-[#f3f5f7]" :to="adminProfilePath" @click="closeAdminUserMenu">
|
||||||
|
내 프로필
|
||||||
|
</NuxtLink>
|
||||||
|
<button class="admin-layout__user-action px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="logoutAdmin">
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-layout__bottom-row flex items-center justify-between gap-3 px-2">
|
||||||
|
<button class="admin-layout__user-trigger flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors hover:bg-[#eceff2]" type="button" :aria-expanded="adminUserMenuOpen" @click="toggleAdminUserMenu">
|
||||||
|
<img
|
||||||
|
v-if="adminAvatarUrl"
|
||||||
|
class="admin-layout__user-trigger-avatar h-8 w-8 rounded-full border border-[#d8dce1] object-cover"
|
||||||
|
:src="adminAvatarUrl"
|
||||||
|
:alt="adminDisplayName"
|
||||||
|
>
|
||||||
|
<span v-else class="admin-layout__user-trigger-avatar flex h-8 w-8 items-center justify-center rounded-full border border-[#d8dce1] bg-[#15171a] text-xs font-semibold text-white">
|
||||||
|
{{ adminAvatarInitial }}
|
||||||
|
</span>
|
||||||
|
<svg class="h-3 w-3 shrink-0 text-[#5d6673]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
|
||||||
|
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<NuxtLink class="admin-layout__bottom-settings flex h-9 w-9 items-center justify-center rounded-md text-[#3f4650] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]" to="/admin/settings" aria-label="설정">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main
|
||||||
|
class="admin-layout__main bg-paper"
|
||||||
|
:class="[
|
||||||
|
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen px-8 py-8 xl:px-12 xl:py-10',
|
||||||
|
{ 'lg:ml-80': !isPostEditorRoute }
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
61
layouts/default.vue
Normal file
61
layouts/default.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup>
|
||||||
|
const { menuOpen, closeMenu } = useMenuState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일에서 좌측 슬라이드 메뉴가 열려 있을 때 문서 스크롤을 잠근다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const syncMobileNavScrollLock = () => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isNarrow = window.matchMedia('(max-width: 1023px)').matches
|
||||||
|
document.documentElement.classList.toggle('site-mobile-nav-open', Boolean(menuOpen.value && isNarrow))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(menuOpen, syncMobileNavScrollLock)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncMobileNavScrollLock()
|
||||||
|
window.addEventListener('resize', syncMobileNavScrollLock)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', syncMobileNavScrollLock)
|
||||||
|
if (import.meta.client) {
|
||||||
|
document.documentElement.classList.remove('site-mobile-nav-open')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="site-shell public-layout">
|
||||||
|
<SiteHeader class="shrink-0" />
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-opacity duration-200 ease-out"
|
||||||
|
leave-active-class="transition-opacity duration-200 ease-in"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-show="menuOpen"
|
||||||
|
class="public-layout__nav-backdrop fixed inset-x-0 top-[57px] bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
@click="closeMenu"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
<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="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
|
||||||
|
>
|
||||||
|
<main
|
||||||
|
class="site-main min-w-0 w-full max-w-full overflow-x-hidden lg:col-start-2 lg:row-start-1 lg:max-w-[720px] lg:justify-self-start"
|
||||||
|
:class="[{ 'site-main--menu-closed': !menuOpen }]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<RightSidebar class="lg:col-start-3 lg:row-start-1" />
|
||||||
|
<LeftSidebar :menu-open="menuOpen" class="lg:col-start-1 lg:row-start-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
5
layouts/page.vue
Normal file
5
layouts/page.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page-layout min-h-screen bg-paper text-ink">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
61
layouts/post.vue
Normal file
61
layouts/post.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup>
|
||||||
|
const { menuOpen, closeMenu } = useMenuState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일에서 좌측 슬라이드 메뉴가 열려 있을 때 문서 스크롤을 잠근다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const syncMobileNavScrollLock = () => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isNarrow = window.matchMedia('(max-width: 1023px)').matches
|
||||||
|
document.documentElement.classList.toggle('site-mobile-nav-open', Boolean(menuOpen.value && isNarrow))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(menuOpen, syncMobileNavScrollLock)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncMobileNavScrollLock()
|
||||||
|
window.addEventListener('resize', syncMobileNavScrollLock)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', syncMobileNavScrollLock)
|
||||||
|
if (import.meta.client) {
|
||||||
|
document.documentElement.classList.remove('site-mobile-nav-open')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="site-shell post-layout">
|
||||||
|
<SiteHeader class="shrink-0" />
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-opacity duration-200 ease-out"
|
||||||
|
leave-active-class="transition-opacity duration-200 ease-in"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-show="menuOpen"
|
||||||
|
class="post-layout__nav-backdrop fixed inset-x-0 top-[57px] bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
@click="closeMenu"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
<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="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
|
||||||
|
>
|
||||||
|
<main
|
||||||
|
class="site-main min-w-0 w-full max-w-full overflow-x-hidden lg:col-start-2 lg:row-start-1 lg:max-w-[720px] lg:justify-self-start"
|
||||||
|
:class="[{ 'site-main--menu-closed': !menuOpen }]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<RightSidebar class="lg:col-start-3 lg:row-start-1" />
|
||||||
|
<LeftSidebar :menu-open="menuOpen" class="lg:col-start-1 lg:row-start-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
44
lib/navigation-editor-tree.js
Normal file
44
lib/navigation-editor-tree.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 관리자 UI용: 동일 location 항목을 부모 기준 트리 래퍼로 만든다(항목 객체는 원본 참조).
|
||||||
|
* @param {Array<Object>} flat - 전체 항목
|
||||||
|
* @param {'primary'|'footer'} location - 위치
|
||||||
|
* @returns {Array<{ item: Object, children: Array<{ item: Object, children: any[] }> }>}
|
||||||
|
*/
|
||||||
|
export const buildNavigationEditorTree = (flat, location) => {
|
||||||
|
const filtered = flat.filter((i) => i.location === location)
|
||||||
|
const nodeMap = new Map()
|
||||||
|
|
||||||
|
for (const item of filtered) {
|
||||||
|
nodeMap.set(String(item.id).trim(), { item, children: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = []
|
||||||
|
|
||||||
|
for (const item of filtered) {
|
||||||
|
const id = String(item.id).trim()
|
||||||
|
const wrap = nodeMap.get(id)
|
||||||
|
const p = item.parentId
|
||||||
|
if (p != null && String(p).trim() !== '') {
|
||||||
|
const pid = String(p).trim()
|
||||||
|
if (nodeMap.has(pid)) {
|
||||||
|
nodeMap.get(pid).children.push(wrap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.push(wrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{ item: Object, children: any[] }>} nodes - 노드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const sortRec = (nodes) => {
|
||||||
|
nodes.sort((a, b) => (a.item.sortOrder || 0) - (b.item.sortOrder || 0))
|
||||||
|
for (const n of nodes) {
|
||||||
|
sortRec(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortRec(roots)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
17
middleware/admin-auth.global.js
Normal file
17
middleware/admin-auth.global.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 관리자 페이지 접근 인증
|
||||||
|
* @param {import('#app').RouteLocationNormalized} to - 이동 대상 라우트
|
||||||
|
* @returns {Promise<ReturnType<typeof navigateTo> | void>} 라우트 이동 결과
|
||||||
|
*/
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
if (!to.path.startsWith('/admin') || to.path.startsWith('/admin/api') || to.path === '/admin/login') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestFetch = import.meta.server ? useRequestFetch() : $fetch
|
||||||
|
await requestFetch('/admin/api/auth/me')
|
||||||
|
} catch {
|
||||||
|
return navigateTo('/admin/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
25
modules/nuxt-ssr-paths-write.mjs
Normal file
25
modules/nuxt-ssr-paths-write.mjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineNuxtModule } from '@nuxt/kit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nuxt 3.21 SSR 번들이 `#internal/nuxt/paths`를 외부 모듈로 두는데,
|
||||||
|
* 기본 `paths.mjs` 템플릿은 `write: true`가 아니어서 `.nuxt/paths.mjs`가 디스크에 없고
|
||||||
|
* Node가 `package.json`의 `imports`로 해석할 실제 파일이 없어 오류가 난다.
|
||||||
|
* 동일 템플릿을 빌드 디렉터리에 기록하도록 한다.
|
||||||
|
* @param {unknown} _options - 모듈 옵션(미사용)
|
||||||
|
* @param {import('@nuxt/schema').Nuxt} nuxt - Nuxt 인스턴스
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export default defineNuxtModule({
|
||||||
|
meta: {
|
||||||
|
name: 'nuxt-ssr-paths-write'
|
||||||
|
},
|
||||||
|
setup(_options, nuxt) {
|
||||||
|
nuxt.hook('app:templates', (app) => {
|
||||||
|
for (const template of app.templates) {
|
||||||
|
if (template.filename === 'paths.mjs') {
|
||||||
|
template.write = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
69
nuxt.config.js
Normal file
69
nuxt.config.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2026-04-29',
|
||||||
|
future: {
|
||||||
|
compatibilityVersion: 3
|
||||||
|
},
|
||||||
|
devtools: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
modules: ['./modules/nuxt-ssr-paths-write.mjs', '@nuxtjs/tailwindcss'],
|
||||||
|
/**
|
||||||
|
* 기본 cssPath(assets/css/tailwind.css)가 없으면 패키지의 tailwind.css가 먼저 주입되어
|
||||||
|
* main.css와 @tailwind 이중 로딩·스타일 꼬임이 날 수 있어 엔트리를 main.css로 통일한다.
|
||||||
|
*/
|
||||||
|
tailwindcss: {
|
||||||
|
cssPath: '~/assets/css/main.css'
|
||||||
|
},
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
path: '~/components',
|
||||||
|
pathPrefix: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
experimental: {
|
||||||
|
appManifest: false
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
interval: 150
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: 'ko'
|
||||||
|
},
|
||||||
|
title: 'sori.studio',
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: 'sori.studio 개인 블로그' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runtimeConfig: {
|
||||||
|
databaseUrl: process.env.DATABASE_URL || '',
|
||||||
|
databaseName: process.env.DATABASE_NAME || '',
|
||||||
|
adminEmail: process.env.ADMIN_EMAIL || '',
|
||||||
|
adminPassword: process.env.ADMIN_PASSWORD || '',
|
||||||
|
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
|
||||||
|
resendApiKey: process.env.RESEND_API_KEY || '',
|
||||||
|
resendFromEmail: process.env.RESEND_FROM_EMAIL || '',
|
||||||
|
emailOtpPepper: process.env.EMAIL_OTP_PEPPER || '',
|
||||||
|
uploadDir: process.env.UPLOAD_DIR || '/uploads',
|
||||||
|
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
|
||||||
|
avatarMinWidth: Number(process.env.AVATAR_MIN_WIDTH || 96),
|
||||||
|
avatarMinHeight: Number(process.env.AVATAR_MIN_HEIGHT || 96),
|
||||||
|
avatarMaxWidth: Number(process.env.AVATAR_MAX_WIDTH || 512),
|
||||||
|
avatarMaxHeight: Number(process.env.AVATAR_MAX_HEIGHT || 512),
|
||||||
|
avatarWebpQuality: Number(process.env.AVATAR_WEBP_QUALITY || 82),
|
||||||
|
public: {
|
||||||
|
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://sori.studio',
|
||||||
|
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || 'sori.studio'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
12649
package-lock.json
generated
Normal file
12649
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "sori.studio",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"imports": {
|
||||||
|
"#internal/nuxt/paths": "./.nuxt/paths.mjs",
|
||||||
|
"#internal/nitro": {
|
||||||
|
"node": "./scripts/node-paths-nitro-shim.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node scripts/dev-server.js",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"lint": "node scripts/check-js-syntax.js",
|
||||||
|
"test": "npm run build",
|
||||||
|
"verify": "npm run lint && npm run test",
|
||||||
|
"preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43117",
|
||||||
|
"db:migrate:dev": "node scripts/migrate-development-db.js",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"nuxt": "^3.21.2",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
pages/admin/index.vue
Normal file
51
pages/admin/index.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: posts } = await useFetch('/admin/api/posts', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
|
||||||
|
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-dashboard">
|
||||||
|
<div class="admin-dashboard__header border-b border-line bg-paper p-6">
|
||||||
|
<p class="admin-dashboard__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Admin
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-dashboard__title mt-2 text-3xl font-semibold">
|
||||||
|
대시보드
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="admin-dashboard__body grid gap-4 bg-paper p-6 text-sm text-muted md:grid-cols-3">
|
||||||
|
<section class="admin-dashboard__metric border border-line bg-white p-4">
|
||||||
|
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
|
||||||
|
Posts
|
||||||
|
</p>
|
||||||
|
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||||
|
{{ posts.length }}
|
||||||
|
</strong>
|
||||||
|
</section>
|
||||||
|
<section class="admin-dashboard__metric border border-line bg-white p-4">
|
||||||
|
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
|
||||||
|
Published
|
||||||
|
</p>
|
||||||
|
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||||
|
{{ publishedCount }}
|
||||||
|
</strong>
|
||||||
|
</section>
|
||||||
|
<section class="admin-dashboard__metric border border-line bg-white p-4">
|
||||||
|
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
|
||||||
|
Draft
|
||||||
|
</p>
|
||||||
|
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||||
|
{{ draftCount }}
|
||||||
|
</strong>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
101
pages/admin/login.vue
Normal file
101
pages/admin/login.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 제출 가능 여부(이메일·비밀번호가 모두 채워졌는지)
|
||||||
|
* @returns {boolean} 제출 가능 여부
|
||||||
|
*/
|
||||||
|
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||||
|
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||||
|
default: () => ({
|
||||||
|
hasUsers: true,
|
||||||
|
needsAdminSetup: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 로그인 제출
|
||||||
|
* @returns {Promise<void>} 로그인 처리 결과
|
||||||
|
*/
|
||||||
|
const submitLogin = async () => {
|
||||||
|
pending.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/admin/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form
|
||||||
|
})
|
||||||
|
await navigateTo('/admin')
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = '이메일 또는 비밀번호를 확인해 주세요.'
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="admin-login flex min-h-screen items-center justify-center bg-[#f5f5f2] px-5 text-ink">
|
||||||
|
<section class="admin-login__panel w-full max-w-sm border border-line bg-paper p-8">
|
||||||
|
<p class="admin-login__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Admin
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-login__title mt-2 text-3xl font-semibold">
|
||||||
|
로그인
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
v-if="bootstrapStatus?.needsAdminSetup"
|
||||||
|
class="mt-3 rounded border border-[#ff4f2e]/30 bg-[#ff4f2e]/10 px-3 py-2 text-xs text-[#b63a23]"
|
||||||
|
>
|
||||||
|
등록된 관리자가 없습니다.
|
||||||
|
<NuxtLink class="font-semibold underline-offset-2 hover:underline" to="/signup">
|
||||||
|
관리자 등록으로 이동
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="admin-login__form mt-8 grid gap-4" @submit.prevent="submitLogin">
|
||||||
|
<label class="admin-login__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-login__label font-medium">이메일</span>
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
class="admin-login__input rounded border border-line bg-white px-3 py-2"
|
||||||
|
type="email"
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="admin-login__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-login__label font-medium">비밀번호</span>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
class="admin-login__input rounded border border-line bg-white px-3 py-2"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<p v-if="errorMessage" class="admin-login__error text-sm text-red-600">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
type="submit"
|
||||||
|
:disabled="pending || !canSubmitAdminLogin"
|
||||||
|
>
|
||||||
|
{{ pending ? '확인 중' : '로그인' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
950
pages/admin/media/index.vue
Normal file
950
pages/admin/media/index.vue
Normal file
@@ -0,0 +1,950 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
|
||||||
|
const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
|
||||||
|
* @param {string} folder - 폴더 경로
|
||||||
|
* @returns {boolean} 썸네일 전용이면 true
|
||||||
|
*/
|
||||||
|
const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || String(folder).startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)
|
||||||
|
|
||||||
|
const activeTab = ref('library')
|
||||||
|
const searchText = ref('')
|
||||||
|
const activeFolder = ref('')
|
||||||
|
const isCreateFolderModalOpen = ref(false)
|
||||||
|
const createFolderModalName = ref('')
|
||||||
|
const deletingFolder = ref('')
|
||||||
|
const editingUrl = ref('')
|
||||||
|
const editingName = ref('')
|
||||||
|
const editingCategory = ref('')
|
||||||
|
const deletingUrl = ref('')
|
||||||
|
const selectedMediaUrl = ref('')
|
||||||
|
const selectedMediaUrls = ref([])
|
||||||
|
const lastSelectedIndex = ref(-1)
|
||||||
|
const draggingUrls = ref([])
|
||||||
|
|
||||||
|
const { toast, showToast } = useAdminToast()
|
||||||
|
|
||||||
|
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 썸네일 디스크 경로 여부
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {boolean} 회원 아바타 경로이면 true
|
||||||
|
*/
|
||||||
|
const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avatars/'))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {boolean} 잠금이면 true
|
||||||
|
*/
|
||||||
|
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
|
||||||
|
|
||||||
|
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item)))
|
||||||
|
|
||||||
|
const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item)))
|
||||||
|
|
||||||
|
const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 탭 전환 시 목록 상태를 초기화한다.
|
||||||
|
* @param {'library' | 'thumbnails'} tab - 선택 탭
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const setActiveTab = (tab) => {
|
||||||
|
if (activeTab.value === tab) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTab.value = tab
|
||||||
|
activeFolder.value = ''
|
||||||
|
searchText.value = ''
|
||||||
|
clearMediaSelection()
|
||||||
|
closeMediaDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 시각을 짧은 로캘 문자열로 표시한다.
|
||||||
|
* @param {string | null} iso - ISO 시각
|
||||||
|
* @returns {string} 표시 문자열
|
||||||
|
*/
|
||||||
|
const formatDateTime = (iso) => {
|
||||||
|
if (!iso) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('ko-KR', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상세 모달에서 선택된 미디어를 브라우저로 내려받는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const downloadSelectedMedia = () => {
|
||||||
|
const item = selectedMedia.value
|
||||||
|
|
||||||
|
if (!item?.url || !import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = item.url
|
||||||
|
anchor.download = item.name || 'image'
|
||||||
|
anchor.rel = 'noopener noreferrer'
|
||||||
|
anchor.target = '_blank'
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
document.body.removeChild(anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
|
||||||
|
default: () => ['미분류']
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null)
|
||||||
|
|
||||||
|
const normalizedFolders = computed(() => {
|
||||||
|
const folderSet = new Set(['미분류'])
|
||||||
|
|
||||||
|
mediaFolders.value.forEach((folder) => {
|
||||||
|
String(folder || '').split('/').filter(Boolean).reduce((parentPath, segment) => {
|
||||||
|
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
|
||||||
|
folderSet.add(nextPath)
|
||||||
|
return nextPath
|
||||||
|
}, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
libraryMediaItems.value.forEach((item) => {
|
||||||
|
String(item.category || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => {
|
||||||
|
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
|
||||||
|
folderSet.add(nextPath)
|
||||||
|
return nextPath
|
||||||
|
}, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...folderSet].sort((left, right) => left.localeCompare(right))
|
||||||
|
})
|
||||||
|
|
||||||
|
const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => {
|
||||||
|
counts[folder] = libraryMediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length
|
||||||
|
return counts
|
||||||
|
}, {}))
|
||||||
|
|
||||||
|
const filteredMediaItems = computed(() => {
|
||||||
|
const query = searchText.value.trim().toLowerCase()
|
||||||
|
const folder = activeFolder.value
|
||||||
|
const base = scopeItems.value
|
||||||
|
|
||||||
|
return base.filter((item) => {
|
||||||
|
const matchesFolder = activeTab.value === 'thumbnails'
|
||||||
|
? true
|
||||||
|
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
|
||||||
|
const usageTitles = item.usage?.map((usage) => usage.title) || []
|
||||||
|
const matchesQuery = !query || [
|
||||||
|
item.name,
|
||||||
|
...usageTitles
|
||||||
|
].some((value) => String(value || '').toLowerCase().includes(query))
|
||||||
|
|
||||||
|
return matchesFolder && matchesQuery
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 크기 표시 문자열 생성
|
||||||
|
* @param {number} size - 파일 크기
|
||||||
|
* @returns {string} 표시 문자열
|
||||||
|
*/
|
||||||
|
const formatFileSize = (size) => {
|
||||||
|
if (size < 1024) {
|
||||||
|
return `${size} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return `${(size / 1024).toFixed(1)} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 경로의 표시 이름 조회
|
||||||
|
* @param {string} folder - 폴더 경로
|
||||||
|
* @returns {string} 표시 이름
|
||||||
|
*/
|
||||||
|
const getFolderName = (folder) => folder.split('/').filter(Boolean).pop() || '미분류'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 깊이 조회
|
||||||
|
* @param {string} folder - 폴더 경로
|
||||||
|
* @returns {number} 폴더 깊이
|
||||||
|
*/
|
||||||
|
const getFolderDepth = (folder) => Math.max(folder.split('/').filter(Boolean).length - 1, 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 선택 여부 확인
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {boolean} 선택 여부
|
||||||
|
*/
|
||||||
|
const isMediaSelected = (item) => selectedMediaUrls.value.includes(item.url)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 선택
|
||||||
|
* @param {string} folder - 폴더 경로
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const selectFolder = (folder) => {
|
||||||
|
activeFolder.value = folder
|
||||||
|
selectedMediaUrls.value = []
|
||||||
|
lastSelectedIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 체크박스로 미디어 선택 토글(Shift 시 범위 추가 선택)
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @param {number} index - 필터 목록 내 순서
|
||||||
|
* @param {MouseEvent} event - 클릭 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleMediaSelection = (item, index, event) => {
|
||||||
|
if (event.shiftKey && lastSelectedIndex.value >= 0) {
|
||||||
|
const startIndex = Math.min(lastSelectedIndex.value, index)
|
||||||
|
const endIndex = Math.max(lastSelectedIndex.value, index)
|
||||||
|
const rangeUrls = filteredMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url)
|
||||||
|
const merged = new Set([...selectedMediaUrls.value, ...rangeUrls])
|
||||||
|
selectedMediaUrls.value = [...merged]
|
||||||
|
lastSelectedIndex.value = index
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedMediaUrls.value = isMediaSelected(item)
|
||||||
|
? selectedMediaUrls.value.filter((url) => url !== item.url)
|
||||||
|
: [...selectedMediaUrls.value, item.url]
|
||||||
|
lastSelectedIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택된 미디어 해제
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const clearMediaSelection = () => {
|
||||||
|
selectedMediaUrls.value = []
|
||||||
|
lastSelectedIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 상세 모달 열기
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openMediaDetail = (item) => {
|
||||||
|
selectedMediaUrl.value = item.url
|
||||||
|
editingUrl.value = item.url
|
||||||
|
editingName.value = item.title
|
||||||
|
editingCategory.value = item.category
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 상세 모달 닫기
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeMediaDetail = () => {
|
||||||
|
selectedMediaUrl.value = ''
|
||||||
|
cancelRename()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 파일명 변경 취소
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const cancelRename = () => {
|
||||||
|
editingUrl.value = ''
|
||||||
|
editingName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 추가 모달을 연다
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openCreateFolderModal = () => {
|
||||||
|
createFolderModalName.value = ''
|
||||||
|
isCreateFolderModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 추가 모달을 닫는다
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeCreateFolderModal = () => {
|
||||||
|
isCreateFolderModalOpen.value = false
|
||||||
|
createFolderModalName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 폴더 생성(모달에서 확인 시)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const submitCreateFolderModal = async () => {
|
||||||
|
if (activeTab.value !== 'library') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderName = createFolderModalName.value.trim()
|
||||||
|
|
||||||
|
if (!folderName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folderPath = activeFolder.value ? `${activeFolder.value}/${folderName}` : folderName
|
||||||
|
const createdFolder = await $fetch('/admin/api/media-folders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
path: folderPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
closeCreateFolderModal()
|
||||||
|
activeFolder.value = createdFolder.path
|
||||||
|
await refreshMediaFolders()
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', error?.data?.message || '폴더를 만들지 못했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 폴더 삭제
|
||||||
|
* @param {string} folder - 삭제할 폴더 경로
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const removeMediaFolder = async (folder) => {
|
||||||
|
if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`"${folder}" 폴더를 삭제할까요? 이 폴더(및 하위 경로)에 속한 미디어는 모두 "미분류"로 옮겨집니다.`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingFolder.value = folder
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/admin/api/media-folders', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: {
|
||||||
|
path: folder
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (activeFolder.value === folder || activeFolder.value.startsWith(`${folder}/`)) {
|
||||||
|
activeFolder.value = ''
|
||||||
|
}
|
||||||
|
selectedMediaUrls.value = []
|
||||||
|
lastSelectedIndex.value = -1
|
||||||
|
await Promise.all([
|
||||||
|
refresh(),
|
||||||
|
refreshMediaFolders()
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', error?.data?.message || '폴더를 삭제하지 못했습니다.')
|
||||||
|
} finally {
|
||||||
|
deletingFolder.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택한 미디어를 폴더로 이동
|
||||||
|
* @param {string} folder - 폴더 경로
|
||||||
|
* @param {Array<string>} urls - 이동할 미디어 URL 목록
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
|
||||||
|
if (activeTab.value !== 'library') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrls = [...new Set(urls.filter(Boolean))]
|
||||||
|
|
||||||
|
if (!targetUrls.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/admin/api/media', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
urls: targetUrls,
|
||||||
|
category: folder || '미분류'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all([
|
||||||
|
refresh(),
|
||||||
|
refreshMediaFolders()
|
||||||
|
])
|
||||||
|
activeFolder.value = folder || '미분류'
|
||||||
|
clearMediaSelection()
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', error?.data?.message || '미디어 폴더를 변경하지 못했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 드래그 시작
|
||||||
|
* @param {DragEvent} event - 드래그 이벤트
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const startMediaDrag = (event, item) => {
|
||||||
|
if (activeTab.value !== 'library') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMediaSelected(item)) {
|
||||||
|
selectedMediaUrls.value = [item.url]
|
||||||
|
}
|
||||||
|
|
||||||
|
draggingUrls.value = [...selectedMediaUrls.value]
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('text/plain', draggingUrls.value.join('\n'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더로 미디어 드롭
|
||||||
|
* @param {string} folder - 폴더 경로
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const dropMediaOnFolder = async (folder) => {
|
||||||
|
if (activeTab.value !== 'library') {
|
||||||
|
draggingUrls.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value
|
||||||
|
draggingUrls.value = []
|
||||||
|
await moveMediaToFolder(folder, urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 카테고리 저장
|
||||||
|
* @returns {Promise<void>} 저장 결과
|
||||||
|
*/
|
||||||
|
const saveMediaCategory = async () => {
|
||||||
|
if (selectedMedia.value?.avatarOwner) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/admin/api/media', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
url: selectedMedia.value.url,
|
||||||
|
category: editingCategory.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all([
|
||||||
|
refresh(),
|
||||||
|
refreshMediaFolders()
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', error?.data?.message || '카테고리를 저장하지 못했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 파일명 변경
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const renameMedia = async () => {
|
||||||
|
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
|
||||||
|
|
||||||
|
if (editingItem && isMediaItemLocked(editingItem)) {
|
||||||
|
showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 파일명을 바꿀 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const renamedItem = await $fetch('/admin/api/media', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
url: editingUrl.value,
|
||||||
|
name: editingName.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cancelRename()
|
||||||
|
await refresh()
|
||||||
|
selectedMediaUrl.value = renamedItem.url
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', error?.data?.message || '파일명을 변경하지 못했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 삭제
|
||||||
|
* @param {Object} item - 미디어 항목
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteMedia = async (item) => {
|
||||||
|
if (isMediaItemLocked(item)) {
|
||||||
|
showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 삭제할 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`"${item.name}" 파일을 삭제할까요?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingUrl.value = item.url
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/admin/api/media', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: {
|
||||||
|
url: item.url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
closeMediaDetail()
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', error?.data?.message || '파일을 삭제하지 못했습니다.')
|
||||||
|
} finally {
|
||||||
|
deletingUrl.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-media bg-paper p-6">
|
||||||
|
<div class="admin-media__header flex flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Media
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-media__title mt-2 text-3xl font-semibold">
|
||||||
|
미디어
|
||||||
|
</h1>
|
||||||
|
<div class="admin-media__tabs mt-3 inline-flex rounded-lg border border-line bg-surface p-0.5">
|
||||||
|
<button
|
||||||
|
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
:class="activeTab === 'library' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
||||||
|
type="button"
|
||||||
|
@click="setActiveTab('library')"
|
||||||
|
>
|
||||||
|
미디어 라이브러리
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
:class="activeTab === 'thumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
||||||
|
type="button"
|
||||||
|
@click="setActiveTab('thumbnails')"
|
||||||
|
>
|
||||||
|
썸네일
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="searchText"
|
||||||
|
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
||||||
|
type="search"
|
||||||
|
:placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||||
|
<aside
|
||||||
|
v-if="activeTab === 'library'"
|
||||||
|
class="admin-media__folders rounded border border-line bg-white p-3"
|
||||||
|
>
|
||||||
|
<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="!activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
|
||||||
|
type="button"
|
||||||
|
@click="selectFolder('')"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="dropMediaOnFolder('미분류')"
|
||||||
|
>
|
||||||
|
<span>전체 미디어</span>
|
||||||
|
<span>{{ libraryMediaItems.length }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="admin-media__folder-list mt-3 grid gap-1">
|
||||||
|
<div
|
||||||
|
v-for="folder in normalizedFolders"
|
||||||
|
:key="folder"
|
||||||
|
class="admin-media__folder-row flex items-stretch rounded"
|
||||||
|
:class="activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
@click="selectFolder(folder)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="dropMediaOnFolder(folder)"
|
||||||
|
>
|
||||||
|
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
|
||||||
|
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
|
||||||
|
class="admin-media__folder-delete mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded text-current opacity-40 transition hover:opacity-100 hover:text-red-300 disabled:opacity-25"
|
||||||
|
type="button"
|
||||||
|
:disabled="deletingFolder === folder"
|
||||||
|
:aria-label="`${folder} 폴더 삭제`"
|
||||||
|
@click.stop="removeMediaFolder(folder)"
|
||||||
|
>
|
||||||
|
<svg class="size-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeTab === 'library'" class="admin-media__folder-actions mt-4 border-t border-line pt-4">
|
||||||
|
<button
|
||||||
|
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
|
||||||
|
type="button"
|
||||||
|
@click="openCreateFolderModal"
|
||||||
|
>
|
||||||
|
폴더 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
v-else
|
||||||
|
class="admin-media__folders admin-media__folders--thumbnails rounded border border-line bg-white p-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold bg-[#15171a] text-white hover:bg-[#15171a]"
|
||||||
|
type="button"
|
||||||
|
@click="selectFolder('')"
|
||||||
|
>
|
||||||
|
<span>전체 썸네일</span>
|
||||||
|
<span>{{ thumbnailMediaItems.length }}</span>
|
||||||
|
</button>
|
||||||
|
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
|
||||||
|
회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 <strong class="font-semibold text-ink">디스크 파일은 삭제되지 않으며</strong> 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="admin-media__content min-w-0">
|
||||||
|
<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">
|
||||||
|
{{ activeTab === 'thumbnails' ? '썸네일' : (activeFolder || '전체 미디어') }}
|
||||||
|
</h2>
|
||||||
|
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
|
||||||
|
{{ filteredMediaItems.length }}개 표시
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
<strong class="admin-media__selection-count text-ink">{{ selectedMediaUrls.length }}개 선택됨</strong>
|
||||||
|
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
|
||||||
|
선택 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in filteredMediaItems"
|
||||||
|
:key="item.url"
|
||||||
|
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
|
||||||
|
:draggable="activeTab === 'library'"
|
||||||
|
@dragstart="startMediaDrag($event, item)"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
:class="isMediaSelected(item)
|
||||||
|
? 'border-white bg-[#15171a] text-white'
|
||||||
|
: 'border-[#394047] bg-white/95 text-[#15171a] hover:border-[#15171a] hover:bg-white'"
|
||||||
|
:aria-label="`${item.name} 선택`"
|
||||||
|
:aria-pressed="isMediaSelected(item)"
|
||||||
|
@click.stop="toggleMediaSelection(item, index, $event)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="isMediaSelected(item)"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="admin-media__thumb relative flex w-full flex-col text-left outline-none"
|
||||||
|
type="button"
|
||||||
|
@click="openMediaDetail(item)"
|
||||||
|
>
|
||||||
|
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||||
|
<span
|
||||||
|
v-if="item.avatarOwner"
|
||||||
|
class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-emerald-800 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
>
|
||||||
|
회원
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="item.usage.length"
|
||||||
|
class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
>
|
||||||
|
{{ item.usage.length }}
|
||||||
|
</span>
|
||||||
|
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="admin-media__empty mt-5 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||||
|
표시할 미디어가 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isCreateFolderModalOpen"
|
||||||
|
class="admin-media__folder-create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="media-folder-modal-title"
|
||||||
|
@click.self="closeCreateFolderModal"
|
||||||
|
>
|
||||||
|
<section class="admin-media__folder-create-panel w-full max-w-sm rounded border border-line bg-white p-5 shadow-xl">
|
||||||
|
<h3 id="media-folder-modal-title" class="text-lg font-semibold text-ink">
|
||||||
|
새 폴더
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-xs text-muted">
|
||||||
|
{{ activeFolder ? `${activeFolder} 아래에 하위 폴더를 만듭니다.` : '최상위 폴더를 만듭니다.' }}
|
||||||
|
</p>
|
||||||
|
<label class="mt-4 grid gap-1.5 text-sm">
|
||||||
|
<span class="text-xs font-semibold text-muted">폴더 이름</span>
|
||||||
|
<input
|
||||||
|
v-model="createFolderModalName"
|
||||||
|
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||||
|
type="text"
|
||||||
|
placeholder="폴더 이름"
|
||||||
|
@keydown.enter.prevent="submitCreateFolderModal"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
|
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeCreateFolderModal">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="!createFolderModalName.trim()"
|
||||||
|
@click="submitCreateFolderModal"
|
||||||
|
>
|
||||||
|
만들기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedMedia"
|
||||||
|
class="admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="closeMediaDetail"
|
||||||
|
>
|
||||||
|
<section class="admin-media__modal-panel grid max-h-[86vh] w-full max-w-5xl overflow-hidden bg-white text-ink shadow-xl lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||||
|
<div class="admin-media__preview grid min-h-[20rem] place-items-center bg-[#f5f5f2] p-5">
|
||||||
|
<img class="admin-media__preview-image max-h-[72vh] max-w-full object-contain" :src="selectedMedia.url" :alt="selectedMedia.title">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
|
||||||
|
<div class="admin-media__detail-header flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Attachment
|
||||||
|
</p>
|
||||||
|
<h2 class="admin-media__detail-title mt-1 break-all text-xl font-semibold">
|
||||||
|
{{ selectedMedia.name }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="admin-media__detail-header-actions flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="admin-media__download rounded border border-line px-3 py-1.5 text-sm font-semibold text-ink hover:bg-surface"
|
||||||
|
type="button"
|
||||||
|
@click="downloadSelectedMedia"
|
||||||
|
>
|
||||||
|
다운로드
|
||||||
|
</button>
|
||||||
|
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="admin-media__info grid gap-3 text-sm">
|
||||||
|
<div class="admin-media__info-row">
|
||||||
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">경로</dt>
|
||||||
|
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.url }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="admin-media__info-row">
|
||||||
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
|
||||||
|
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="admin-media__category grid gap-2">
|
||||||
|
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
|
||||||
|
폴더
|
||||||
|
</label>
|
||||||
|
<div class="admin-media__category-row flex gap-2">
|
||||||
|
<input
|
||||||
|
id="media-category"
|
||||||
|
v-model="editingCategory"
|
||||||
|
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-surface disabled:opacity-70"
|
||||||
|
type="text"
|
||||||
|
list="media-folder-options"
|
||||||
|
placeholder="미분류"
|
||||||
|
:disabled="Boolean(selectedMedia.avatarOwner)"
|
||||||
|
@keydown.enter.prevent="saveMediaCategory"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="Boolean(selectedMedia.avatarOwner)"
|
||||||
|
@click="saveMediaCategory"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="selectedMedia.avatarOwner" class="admin-media__category-hint text-xs text-muted">
|
||||||
|
프로필 썸네일의 논리 폴더는 「{{ MEDIA_THUMBNAIL_ROOT }}」로 고정됩니다.
|
||||||
|
</p>
|
||||||
|
<datalist id="media-folder-options">
|
||||||
|
<option v-for="folder in normalizedFolders" :key="folder" :value="folder" />
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedMedia.avatarOwner"
|
||||||
|
class="admin-media__avatar-owner rounded border border-line bg-surface p-3 text-xs"
|
||||||
|
>
|
||||||
|
<strong class="admin-media__avatar-owner-title text-ink">연결된 회원</strong>
|
||||||
|
<dl class="admin-media__avatar-owner-fields mt-2 grid gap-2 text-muted">
|
||||||
|
<div class="admin-media__avatar-owner-row">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">닉네임</dt>
|
||||||
|
<dd class="mt-0.5 font-semibold text-ink">{{ selectedMedia.avatarOwner.username }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="admin-media__avatar-owner-row">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">이메일</dt>
|
||||||
|
<dd class="mt-0.5 break-all text-ink">{{ selectedMedia.avatarOwner.email }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="admin-media__avatar-owner-row">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 접속</dt>
|
||||||
|
<dd class="mt-0.5 text-ink">{{ formatDateTime(selectedMedia.avatarOwner.lastSeenAt) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMedia.avatarOwner.lastSeenIp" class="admin-media__avatar-owner-row">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 IP</dt>
|
||||||
|
<dd class="mt-0.5 break-all font-mono text-[11px] text-ink">{{ selectedMedia.avatarOwner.lastSeenIp }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!selectedMedia.avatarOwner" class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||||
|
<strong class="admin-media__usage-title text-ink">
|
||||||
|
사용 현황 {{ selectedMedia.usage.length }}곳
|
||||||
|
</strong>
|
||||||
|
<ul v-if="selectedMedia.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
|
||||||
|
<li v-for="usage in selectedMedia.usage" :key="`${selectedMedia.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="usage.adminUrl"
|
||||||
|
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
|
||||||
|
:to="usage.adminUrl"
|
||||||
|
>
|
||||||
|
{{ usage.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="admin-media__usage-name font-semibold text-ink">{{ usage.title }}</span>
|
||||||
|
<span class="admin-media__usage-meta"> · {{ usage.typeLabel }} · {{ usage.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="admin-media__usage-empty mt-2 text-muted">
|
||||||
|
사용 중인 곳이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-media__rename grid gap-2">
|
||||||
|
<label class="admin-media__rename-label text-xs font-semibold text-muted" for="media-name">
|
||||||
|
파일명
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="media-name"
|
||||||
|
v-model="editingName"
|
||||||
|
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
|
||||||
|
type="text"
|
||||||
|
:disabled="isMediaItemLocked(selectedMedia)"
|
||||||
|
:placeholder="selectedMedia.title"
|
||||||
|
@keydown.enter.prevent="renameMedia"
|
||||||
|
>
|
||||||
|
<p v-if="isMediaItemLocked(selectedMedia)" class="admin-media__locked text-xs text-muted">
|
||||||
|
게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 잠깁니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-media__actions flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="isMediaItemLocked(selectedMedia) || !editingName"
|
||||||
|
@click="renameMedia"
|
||||||
|
>
|
||||||
|
파일명 저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="deletingUrl === selectedMedia.url || isMediaItemLocked(selectedMedia)"
|
||||||
|
@click="deleteMedia(selectedMedia)"
|
||||||
|
>
|
||||||
|
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="toast"
|
||||||
|
class="admin-media__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||||
|
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||||
|
'border-line bg-white text-ink': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
44
pages/admin/members/[id].vue
Normal file
44
pages/admin/members/[id].vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const memberId = computed(() => String(route.params.id || ''))
|
||||||
|
|
||||||
|
const { data: member, error } = await useFetch(() => `/admin/api/members/${memberId.value}`, {
|
||||||
|
default: () => null
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장된 회원 정보로 화면 상태를 갱신한다.
|
||||||
|
* @param {Object} savedMember - 저장된 회원
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleMemberSaved = (savedMember) => {
|
||||||
|
member.value = savedMember
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 후 목록 화면으로 이동한다.
|
||||||
|
* @returns {Promise<void>} 이동 처리
|
||||||
|
*/
|
||||||
|
const handleMemberDeleted = async () => {
|
||||||
|
await navigateTo('/admin/members')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminMemberForm
|
||||||
|
v-if="member"
|
||||||
|
:member="member"
|
||||||
|
mode="edit"
|
||||||
|
@saved="handleMemberSaved"
|
||||||
|
@deleted="handleMemberDeleted"
|
||||||
|
/>
|
||||||
|
<section v-else class="admin-member-detail bg-paper p-6">
|
||||||
|
<div class="rounded-xl border border-line bg-white px-5 py-8 text-sm text-muted">
|
||||||
|
{{ error?.data?.message || '회원 정보를 불러올 수 없습니다.' }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
577
pages/admin/members/index.vue
Normal file
577
pages/admin/members/index.vue
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberSearchQuery = ref('')
|
||||||
|
const isFilterOpen = ref(false)
|
||||||
|
const filterDrafts = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
field: 'email',
|
||||||
|
operator: 'contains',
|
||||||
|
value: ''
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const activeFilters = ref([])
|
||||||
|
let nextFilterId = 2
|
||||||
|
|
||||||
|
const { data: members } = await useFetch('/admin/api/members', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateFilterFields = ['lastSeenAt', 'createdAt']
|
||||||
|
|
||||||
|
const filterFieldOptions = [
|
||||||
|
{ value: 'name', label: '이름' },
|
||||||
|
{ value: 'email', label: '이메일' },
|
||||||
|
{ value: 'label', label: '레이블' },
|
||||||
|
{ value: 'status', label: '상태' },
|
||||||
|
{ value: 'lastSeenAt', label: '최근 접속' },
|
||||||
|
{ value: 'createdAt', label: '가입일' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const operatorOptionsByFieldType = {
|
||||||
|
text: [
|
||||||
|
{ value: 'contains', label: '포함' },
|
||||||
|
{ value: 'notContains', label: '포함하지 않음' },
|
||||||
|
{ value: 'is', label: '일치' },
|
||||||
|
{ value: 'startsWith', label: '시작' },
|
||||||
|
{ value: 'endsWith', label: '끝남' }
|
||||||
|
],
|
||||||
|
status: [
|
||||||
|
{ value: 'is', label: '일치' },
|
||||||
|
{ value: 'isNot', label: '일치하지 않음' }
|
||||||
|
],
|
||||||
|
date: [
|
||||||
|
{ value: 'before', label: '이전' },
|
||||||
|
{ value: 'after', label: '이후' },
|
||||||
|
{ value: 'empty', label: '없음' },
|
||||||
|
{ value: 'notEmpty', label: '있음' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusFilterOptions = [
|
||||||
|
{ value: '활성', label: '활성' },
|
||||||
|
{ value: '비활성', label: '비활성' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 필드 타입을 반환한다.
|
||||||
|
* @param {string} field - 필터 필드
|
||||||
|
* @returns {'text'|'status'|'date'} 필드 타입
|
||||||
|
*/
|
||||||
|
const getFilterFieldType = (field) => {
|
||||||
|
if (field === 'status') {
|
||||||
|
return 'status'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFilterFields.includes(field)) {
|
||||||
|
return 'date'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 필드에 맞는 연산자 목록을 반환한다.
|
||||||
|
* @param {string} field - 필터 필드
|
||||||
|
* @returns {Array<{ value: string, label: string }>} 연산자 목록
|
||||||
|
*/
|
||||||
|
const getFilterOperatorOptions = (field) => operatorOptionsByFieldType[getFilterFieldType(field)]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 대상 회원 값을 반환한다.
|
||||||
|
* @param {Object} member - 회원 정보
|
||||||
|
* @param {string} field - 필터 필드
|
||||||
|
* @returns {string} 필터 대상 값
|
||||||
|
*/
|
||||||
|
const getFilterMemberValue = (member, field) => {
|
||||||
|
if (field === 'name') {
|
||||||
|
return member.username || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'label') {
|
||||||
|
return Array.isArray(member.labels) ? member.labels.join(', ') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'status') {
|
||||||
|
return member.activityStatus || '비활성'
|
||||||
|
}
|
||||||
|
|
||||||
|
return member[field] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 필터 조건을 검사한다.
|
||||||
|
* @param {string} source - 원본 값
|
||||||
|
* @param {string} operator - 연산자
|
||||||
|
* @param {string} target - 비교 값
|
||||||
|
* @returns {boolean} 조건 충족 여부
|
||||||
|
*/
|
||||||
|
const matchesTextFilter = (source, operator, target) => {
|
||||||
|
const sourceText = source.toLowerCase()
|
||||||
|
const targetText = target.toLowerCase()
|
||||||
|
|
||||||
|
if (!targetText) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'is') {
|
||||||
|
return sourceText === targetText
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'notContains') {
|
||||||
|
return !sourceText.includes(targetText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'startsWith') {
|
||||||
|
return sourceText.startsWith(targetText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'endsWith') {
|
||||||
|
return sourceText.endsWith(targetText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceText.includes(targetText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 필터 조건을 검사한다.
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @param {string} operator - 연산자
|
||||||
|
* @param {string} target - yyyy-mm-dd 비교 값
|
||||||
|
* @returns {boolean} 조건 충족 여부
|
||||||
|
*/
|
||||||
|
const matchesDateFilter = (value, operator, target) => {
|
||||||
|
if (operator === 'empty') {
|
||||||
|
return !value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'notEmpty') {
|
||||||
|
return Boolean(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || !target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceTime = new Date(value).getTime()
|
||||||
|
const targetTime = new Date(`${target}T00:00:00`).getTime()
|
||||||
|
|
||||||
|
if (Number.isNaN(sourceTime) || Number.isNaN(targetTime)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return operator === 'before'
|
||||||
|
? sourceTime < targetTime
|
||||||
|
: sourceTime >= targetTime
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원이 단일 필터 조건을 만족하는지 확인한다.
|
||||||
|
* @param {Object} member - 회원 정보
|
||||||
|
* @param {Object} filter - 필터 조건
|
||||||
|
* @returns {boolean} 조건 충족 여부
|
||||||
|
*/
|
||||||
|
const matchesMemberFilter = (member, filter) => {
|
||||||
|
const fieldType = getFilterFieldType(filter.field)
|
||||||
|
const source = String(getFilterMemberValue(member, filter.field) || '')
|
||||||
|
|
||||||
|
if (fieldType === 'date') {
|
||||||
|
return matchesDateFilter(source, filter.operator, filter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === 'status') {
|
||||||
|
return filter.operator === 'isNot'
|
||||||
|
? source !== filter.value
|
||||||
|
: source === filter.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchesTextFilter(source, filter.operator, filter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건 필드를 변경한다.
|
||||||
|
* @param {Object} filter - 필터 조건
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const changeFilterField = (filter) => {
|
||||||
|
const fieldType = getFilterFieldType(filter.field)
|
||||||
|
filter.operator = getFilterOperatorOptions(filter.field)[0].value
|
||||||
|
filter.value = fieldType === 'status' ? '활성' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 필터 조건을 추가한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const addFilter = () => {
|
||||||
|
filterDrafts.value.push({
|
||||||
|
id: nextFilterId,
|
||||||
|
field: 'email',
|
||||||
|
operator: 'contains',
|
||||||
|
value: ''
|
||||||
|
})
|
||||||
|
nextFilterId += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건을 삭제한다.
|
||||||
|
* @param {number} id - 필터 ID
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const removeFilter = (id) => {
|
||||||
|
filterDrafts.value = filterDrafts.value.filter((filter) => filter.id !== id)
|
||||||
|
|
||||||
|
if (!filterDrafts.value.length) {
|
||||||
|
addFilter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건을 적용한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const applyFilters = () => {
|
||||||
|
activeFilters.value = filterDrafts.value
|
||||||
|
.filter((filter) => ['empty', 'notEmpty'].includes(filter.operator) || String(filter.value || '').trim())
|
||||||
|
.map((filter) => ({ ...filter }))
|
||||||
|
isFilterOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 필터 조건을 초기화한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const resetFilters = () => {
|
||||||
|
filterDrafts.value = [
|
||||||
|
{
|
||||||
|
id: nextFilterId,
|
||||||
|
field: 'email',
|
||||||
|
operator: 'contains',
|
||||||
|
value: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
nextFilterId += 1
|
||||||
|
activeFilters.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMembers = computed(() => {
|
||||||
|
const query = memberSearchQuery.value.trim().toLowerCase()
|
||||||
|
const searchedMembers = !query
|
||||||
|
? members.value
|
||||||
|
: members.value.filter((member) => [
|
||||||
|
member.username,
|
||||||
|
member.email,
|
||||||
|
member.lastSeenIp,
|
||||||
|
member.activityStatus
|
||||||
|
].some((value) => String(value || '').toLowerCase().includes(query)))
|
||||||
|
|
||||||
|
if (!activeFilters.value.length) {
|
||||||
|
return searchedMembers
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchedMembers.filter((member) => activeFilters.value.every((filter) => matchesMemberFilter(member, filter)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberCountLabel = computed(() => {
|
||||||
|
const count = filteredMembers.value.length
|
||||||
|
return `${count}명`
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => activeFilters.value.length)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 상세 화면으로 이동한다.
|
||||||
|
* @param {Object} member - 회원 정보
|
||||||
|
* @returns {Promise<void>} 이동 처리
|
||||||
|
*/
|
||||||
|
const navigateToMember = async (member) => {
|
||||||
|
if (!member?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigateTo(`/admin/members/${member.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 이니셜을 반환한다.
|
||||||
|
* @param {Object} member - 회원 정보
|
||||||
|
* @returns {string} 이니셜
|
||||||
|
*/
|
||||||
|
const getMemberInitial = (member) => String(member?.username || member?.email || '?').slice(0, 1).toUpperCase()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 화면 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 활동 시각을 상대 시간으로 표시한다.
|
||||||
|
* @param {string | null} value - ISO 시각
|
||||||
|
* @returns {string} 상대 시간
|
||||||
|
*/
|
||||||
|
const formatRelativeTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '최근 활동 없음'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '최근 활동 없음'
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = Date.now() - date.getTime()
|
||||||
|
const minute = 1000 * 60
|
||||||
|
const hour = minute * 60
|
||||||
|
const day = hour * 24
|
||||||
|
|
||||||
|
if (diffMs < minute) {
|
||||||
|
return '방금 전'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < hour) {
|
||||||
|
return `${Math.floor(diffMs / minute)}분 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < day) {
|
||||||
|
return `${Math.floor(diffMs / hour)}시간 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < day * 30) {
|
||||||
|
return `${Math.floor(diffMs / day)}일 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-members bg-paper p-6">
|
||||||
|
<div class="admin-members__header flex flex-col gap-5 pb-6 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="admin-members__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Members
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-members__title mt-2 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||||
|
멤버
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="admin-members__actions flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<label class="admin-members__search relative block w-full sm:w-[290px]">
|
||||||
|
<span class="sr-only">멤버 검색</span>
|
||||||
|
<svg class="admin-members__search-icon pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9aa4b2]" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M23.245 23.996a.743.743 0 01-.53-.22L16.2 17.26a9.824 9.824 0 01-2.553 1.579 9.766 9.766 0 01-7.51.069 9.745 9.745 0 01-5.359-5.262c-1.025-2.412-1.05-5.08-.069-7.51S3.558 1.802 5.97.777a9.744 9.744 0 017.51-.069 9.745 9.745 0 015.359 5.262 9.748 9.748 0 01.069 7.51 9.807 9.807 0 01-1.649 2.718l6.517 6.518a.75.75 0 01-.531 1.28zM9.807 1.49a8.259 8.259 0 00-3.25.667 8.26 8.26 0 00-4.458 4.54 8.26 8.26 0 00.058 6.362 8.26 8.26 0 004.54 4.458 8.259 8.259 0 006.362-.059 8.285 8.285 0 002.594-1.736.365.365 0 01.077-.076 8.245 8.245 0 001.786-2.728 8.255 8.255 0 00-.059-6.362 8.257 8.257 0 00-4.54-4.458 8.28 8.28 0 00-3.11-.608z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="memberSearchQuery"
|
||||||
|
class="admin-members__search-input h-11 w-full rounded-md border border-transparent bg-[#eef1f4] pl-10 pr-3 text-sm text-[#15171a] outline-none transition focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]"
|
||||||
|
type="search"
|
||||||
|
placeholder="멤버 검색..."
|
||||||
|
aria-label="멤버 검색"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="admin-members__filter inline-flex h-11 items-center justify-center gap-2 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2] hover:text-black"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="isFilterOpen"
|
||||||
|
@click="isFilterOpen = !isFilterOpen"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6.5 13.502H12M4 9.004L14 9M1.688 4.502h14.624" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>필터</span>
|
||||||
|
<span v-if="activeFilterCount" class="admin-members__filter-count rounded-full bg-[#15171a] px-1.5 py-0.5 text-[11px] leading-none text-white">
|
||||||
|
{{ activeFilterCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
class="admin-members__new inline-flex h-11 items-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-[#2b2f35] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-2"
|
||||||
|
to="/admin/members/new"
|
||||||
|
>
|
||||||
|
멤버 추가
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="isFilterOpen" class="admin-members__filter-panel mt-2 rounded-lg border border-line bg-white p-6 shadow-xl">
|
||||||
|
<h2 class="admin-members__filter-title text-2xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||||
|
필터 목록
|
||||||
|
</h2>
|
||||||
|
<div class="admin-members__filter-body mt-6 rounded-md bg-[#eef1f4] p-5">
|
||||||
|
<div class="admin-members__filter-rules grid gap-3">
|
||||||
|
<div
|
||||||
|
v-for="filter in filterDrafts"
|
||||||
|
:key="filter.id"
|
||||||
|
class="admin-members__filter-row grid gap-2 lg:grid-cols-[minmax(0,1fr)_220px_minmax(0,1fr)_36px]"
|
||||||
|
>
|
||||||
|
<label class="admin-members__filter-field">
|
||||||
|
<span class="sr-only">필터 항목</span>
|
||||||
|
<select
|
||||||
|
v-model="filter.field"
|
||||||
|
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||||
|
@change="changeFilterField(filter)"
|
||||||
|
>
|
||||||
|
<option v-for="option in filterFieldOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="admin-members__filter-operator">
|
||||||
|
<span class="sr-only">필터 조건</span>
|
||||||
|
<select
|
||||||
|
v-model="filter.operator"
|
||||||
|
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||||
|
>
|
||||||
|
<option v-for="option in getFilterOperatorOptions(filter.field)" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="admin-members__filter-value">
|
||||||
|
<span class="sr-only">필터 값</span>
|
||||||
|
<select
|
||||||
|
v-if="getFilterFieldType(filter.field) === 'status'"
|
||||||
|
v-model="filter.value"
|
||||||
|
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||||
|
>
|
||||||
|
<option v-for="option in statusFilterOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-else-if="getFilterFieldType(filter.field) === 'date' && !['empty', 'notEmpty'].includes(filter.operator)"
|
||||||
|
v-model="filter.value"
|
||||||
|
class="admin-members__filter-input h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||||
|
type="date"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-else-if="getFilterFieldType(filter.field) !== 'date'"
|
||||||
|
v-model="filter.value"
|
||||||
|
class="admin-members__filter-input h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||||
|
type="text"
|
||||||
|
placeholder="값 입력"
|
||||||
|
>
|
||||||
|
<span v-else class="admin-members__filter-empty-value flex h-11 items-center rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#8a95a5]">
|
||||||
|
값 필요 없음
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="admin-members__filter-delete grid h-11 w-9 place-items-center rounded-md text-[#657080] transition hover:bg-white hover:text-[#15171a]"
|
||||||
|
type="button"
|
||||||
|
aria-label="필터 삭제"
|
||||||
|
@click="removeFilter(filter.id)"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="admin-members__filter-add mt-5 inline-flex items-center gap-2 text-sm font-semibold text-[#17a62b]"
|
||||||
|
type="button"
|
||||||
|
@click="addFilter"
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M8 1v14M1 8h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>필터 추가</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-members__filter-footer mt-5 flex items-center justify-between">
|
||||||
|
<button class="admin-members__filter-reset h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2]" type="button" @click="resetFilters">
|
||||||
|
모두 초기화
|
||||||
|
</button>
|
||||||
|
<button class="admin-members__filter-apply h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black" type="button" @click="applyFilters">
|
||||||
|
필터 적용
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-members__table mt-7 overflow-x-auto">
|
||||||
|
<table class="admin-members__table-inner min-w-[920px] w-full border-collapse text-left text-sm">
|
||||||
|
<thead class="admin-members__table-head border-b border-line text-xs uppercase tracking-[0.02em] text-[#15171a]">
|
||||||
|
<tr>
|
||||||
|
<th class="admin-members__cell w-[32%] py-4 pr-5 font-semibold">{{ memberCountLabel }}</th>
|
||||||
|
<th class="admin-members__cell w-[15%] px-5 py-4 font-semibold">상태</th>
|
||||||
|
<th class="admin-members__cell w-[16%] px-5 py-4 font-semibold">댓글 작성</th>
|
||||||
|
<th class="admin-members__cell w-[17%] px-5 py-4 font-semibold">접속 IP</th>
|
||||||
|
<th class="admin-members__cell w-[20%] py-4 pl-5 font-semibold">가입일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="admin-members__table-body divide-y divide-line/70 bg-paper">
|
||||||
|
<tr
|
||||||
|
v-for="member in filteredMembers"
|
||||||
|
:key="member.id"
|
||||||
|
class="admin-members__row cursor-pointer align-middle transition-colors hover:bg-[#f7f8fa] focus-within:bg-[#f7f8fa]"
|
||||||
|
tabindex="0"
|
||||||
|
@click="navigateToMember(member)"
|
||||||
|
@keydown.enter.prevent="navigateToMember(member)"
|
||||||
|
>
|
||||||
|
<td class="admin-members__cell py-5 pr-5">
|
||||||
|
<div class="admin-members__profile flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
v-if="member.avatarUrl"
|
||||||
|
:src="member.avatarUrl"
|
||||||
|
:alt="member.username"
|
||||||
|
class="admin-members__avatar h-11 w-11 shrink-0 rounded-full object-cover"
|
||||||
|
>
|
||||||
|
<span v-else class="admin-members__avatar grid h-11 w-11 shrink-0 place-items-center rounded-full bg-[#15171a] text-sm font-semibold text-white">
|
||||||
|
{{ getMemberInitial(member) }}
|
||||||
|
</span>
|
||||||
|
<span class="admin-members__identity min-w-0">
|
||||||
|
<span class="admin-members__name block truncate text-base font-semibold text-[#15171a]">
|
||||||
|
{{ member.username || '이름 없음' }}
|
||||||
|
</span>
|
||||||
|
<span class="admin-members__email mt-0.5 block truncate text-sm text-[#657080]">
|
||||||
|
{{ member.email }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
||||||
|
<span class="admin-members__status text-sm text-[#394047]">
|
||||||
|
{{ member.activityStatus }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
||||||
|
<span class="admin-members__comments font-semibold text-[#15171a]">{{ member.commentCount }}</span>
|
||||||
|
<span class="admin-members__comments-label ml-1 text-xs text-[#8c96a3]">개</span>
|
||||||
|
</td>
|
||||||
|
<td class="admin-members__cell px-5 py-5 text-sm text-[#657080]">
|
||||||
|
{{ member.lastSeenIp || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="admin-members__cell py-5 pl-5">
|
||||||
|
<span class="admin-members__created block text-sm text-[#15171a]">{{ formatDate(member.createdAt) }}</span>
|
||||||
|
<span class="admin-members__last-seen mt-1 block text-sm text-[#9aa4b2]">{{ formatRelativeTime(member.lastSeenAt) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="filteredMembers.length === 0">
|
||||||
|
<td colspan="5" class="admin-members__empty px-4 py-12 text-center text-sm text-muted">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
22
pages/admin/members/new.vue
Normal file
22
pages/admin/members/new.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 회원 저장 후 상세 화면으로 이동한다.
|
||||||
|
* @param {Object} member - 저장된 회원
|
||||||
|
* @returns {Promise<void>} 이동 처리
|
||||||
|
*/
|
||||||
|
const handleMemberSaved = async (member) => {
|
||||||
|
if (!member?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigateTo(`/admin/members/${member.id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminMemberForm mode="new" @saved="handleMemberSaved" />
|
||||||
|
</template>
|
||||||
540
pages/admin/navigation/index.vue
Normal file
540
pages/admin/navigation/index.vue
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
<script setup>
|
||||||
|
import { buildNavigationEditorTree } from '~/lib/navigation-editor-tree.js'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const activeTab = ref('primary')
|
||||||
|
const { toast, showToast, clearToast } = useAdminToast()
|
||||||
|
|
||||||
|
const { data: navigationItems } = await useFetch('/admin/api/navigation', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = ref(navigationItems.value.map((item) => ({
|
||||||
|
...item,
|
||||||
|
parentId: item.parentId ?? null,
|
||||||
|
isFolder: Boolean(item.isFolder),
|
||||||
|
isVisible: true
|
||||||
|
})))
|
||||||
|
|
||||||
|
const navDraggingId = ref('')
|
||||||
|
const navDragParentKey = ref('')
|
||||||
|
const navDragOverId = ref('')
|
||||||
|
|
||||||
|
const footerDraggingId = ref('')
|
||||||
|
const footerDragOverId = ref('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네비게이션 항목을 저장 API와 동일한 형태로 직렬화한다.
|
||||||
|
* @param {Array<Object>} list - 항목 목록
|
||||||
|
* @returns {string} 비교용 JSON 문자열
|
||||||
|
*/
|
||||||
|
const serializeNavigationItems = (list) => JSON.stringify(
|
||||||
|
[...list]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.location !== b.location) {
|
||||||
|
return String(a.location).localeCompare(String(b.location))
|
||||||
|
}
|
||||||
|
return (a.sortOrder || 0) - (b.sortOrder || 0)
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
id: String(item.id || '').trim(),
|
||||||
|
label: String(item.label || '').trim(),
|
||||||
|
url: String(item.url || '').trim(),
|
||||||
|
location: item.location,
|
||||||
|
sortOrder: Number(item.sortOrder || 0),
|
||||||
|
parentId: item.parentId ? String(item.parentId).trim() : null
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 서버에서 불러온 네비게이션 직렬화 스냅샷 */
|
||||||
|
const navigationBaseline = ref(serializeNavigationItems(items.value))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 편집본이 서버 스냅샷과 다른지 여부
|
||||||
|
* @returns {boolean} 변경 여부
|
||||||
|
*/
|
||||||
|
const isNavigationDirty = computed(() => serializeNavigationItems(items.value) !== navigationBaseline.value)
|
||||||
|
|
||||||
|
const primaryTree = computed(() => buildNavigationEditorTree(items.value, 'primary'))
|
||||||
|
|
||||||
|
const footerItemsSorted = computed(() =>
|
||||||
|
items.value
|
||||||
|
.filter((item) => item.location === 'footer' && !item.parentId)
|
||||||
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하위 항목 id를 모두 모은 뒤 삭제한다.
|
||||||
|
* @param {string} rootId - 루트 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const removeItemCascade = (rootId) => {
|
||||||
|
const toRemove = new Set([String(rootId)])
|
||||||
|
let growing = true
|
||||||
|
while (growing) {
|
||||||
|
growing = false
|
||||||
|
for (const it of items.value) {
|
||||||
|
const pid = it.parentId ? String(it.parentId) : ''
|
||||||
|
if (pid && toRemove.has(pid) && !toRemove.has(String(it.id))) {
|
||||||
|
toRemove.add(String(it.id))
|
||||||
|
growing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.value = items.value.filter((i) => !toRemove.has(String(i.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 메뉴 동일 부모 형제 사이에서 순서만 바꾼다.
|
||||||
|
* @param {string|null} parentId - 부모 id
|
||||||
|
* @param {string} sourceId - 이동할 id
|
||||||
|
* @param {string} targetId - 놓인 위치 기준 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const reorderPrimarySiblings = (parentId, sourceId, targetId) => {
|
||||||
|
const pid = parentId || null
|
||||||
|
const siblings = items.value
|
||||||
|
.filter((i) => i.location === 'primary' && (pid ? i.parentId === pid : !i.parentId))
|
||||||
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||||
|
const ids = siblings.map((s) => String(s.id))
|
||||||
|
const si = ids.indexOf(String(sourceId))
|
||||||
|
const ti = ids.indexOf(String(targetId))
|
||||||
|
if (si < 0 || ti < 0 || si === ti) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextOrder = [...siblings]
|
||||||
|
const [mv] = nextOrder.splice(si, 1)
|
||||||
|
nextOrder.splice(ti, 0, mv)
|
||||||
|
nextOrder.forEach((row, idx) => {
|
||||||
|
row.sortOrder = (idx + 1) * 10
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하단 메뉴 순서 변경
|
||||||
|
* @param {string} sourceId - 이동할 id
|
||||||
|
* @param {string} targetId - 기준 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const reorderFooter = (sourceId, targetId) => {
|
||||||
|
const list = footerItemsSorted.value.map((i) => i)
|
||||||
|
const ids = list.map((s) => String(s.id))
|
||||||
|
const si = ids.indexOf(String(sourceId))
|
||||||
|
const ti = ids.indexOf(String(targetId))
|
||||||
|
if (si < 0 || ti < 0 || si === ti) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [mv] = list.splice(si, 1)
|
||||||
|
list.splice(ti, 0, mv)
|
||||||
|
list.forEach((row, idx) => {
|
||||||
|
row.sortOrder = (idx + 1) * 10
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 트리 드래그 시작
|
||||||
|
* @param {{ parentKey: string, itemId: string }} payload - 부모 키와 항목 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onPrimaryDragStart = ({ parentKey, itemId }) => {
|
||||||
|
navDragParentKey.value = parentKey
|
||||||
|
navDraggingId.value = itemId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 트리 드래그 오버
|
||||||
|
* @param {string} itemId - 항목 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onPrimaryDragOver = (itemId) => {
|
||||||
|
navDragOverId.value = itemId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 트리 드래그 종료
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onPrimaryDragEnd = () => {
|
||||||
|
navDraggingId.value = ''
|
||||||
|
navDragParentKey.value = ''
|
||||||
|
navDragOverId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 트리 드롭
|
||||||
|
* @param {{ parentKey: string, targetId: string }} payload - 부모 키와 대상 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onPrimaryDrop = ({ parentKey, targetId }) => {
|
||||||
|
if (navDragParentKey.value !== parentKey) {
|
||||||
|
showToast('error', '같은 단계의 메뉴 안에서만 순서를 바꿀 수 있습니다.')
|
||||||
|
onPrimaryDragEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parentId = parentKey === 'root' ? null : parentKey
|
||||||
|
reorderPrimarySiblings(parentId, navDraggingId.value, targetId)
|
||||||
|
onPrimaryDragEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
|
||||||
|
* @param {DragEvent} event - 이벤트
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
const shouldBlockFooterRowDrag = (event) => {
|
||||||
|
const el = event.target
|
||||||
|
if (!el || typeof el.closest !== 'function') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return Boolean(el.closest('input, button, textarea, select, a'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하단 행 드래그 시작
|
||||||
|
* @param {DragEvent} event - 이벤트
|
||||||
|
* @param {string} id - 항목 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onFooterDragStart = (event, id) => {
|
||||||
|
if (shouldBlockFooterRowDrag(event)) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!event.dataTransfer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
footerDraggingId.value = id
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하단 행 드래그 오버
|
||||||
|
* @param {DragEvent} event - 이벤트
|
||||||
|
* @param {string} id - 항목 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onFooterDragOver = (event, id) => {
|
||||||
|
event.preventDefault()
|
||||||
|
footerDragOverId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하단 행 드롭
|
||||||
|
* @param {DragEvent} event - 이벤트
|
||||||
|
* @param {string} targetId - 대상 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onFooterDrop = (event, targetId) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!footerDraggingId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reorderFooter(footerDraggingId.value, targetId)
|
||||||
|
footerDraggingId.value = ''
|
||||||
|
footerDragOverId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하단 드래그 종료
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onFooterDragEnd = () => {
|
||||||
|
footerDraggingId.value = ''
|
||||||
|
footerDragOverId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하단 행 하이라이트(태그 관리와 동일)
|
||||||
|
* @param {string} id - 항목 id
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const footerRowClass = (id) => {
|
||||||
|
if (footerDragOverId.value === id) {
|
||||||
|
return 'bg-[#f9f9f7]'
|
||||||
|
}
|
||||||
|
if (footerDraggingId.value === id) {
|
||||||
|
return 'opacity-50'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 루트 항목 추가
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const addPrimaryRoot = () => {
|
||||||
|
const roots = items.value.filter((i) => i.location === 'primary' && !i.parentId)
|
||||||
|
const maxOrder = Math.max(0, ...roots.map((r) => r.sortOrder || 0))
|
||||||
|
items.value.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: '새 메뉴',
|
||||||
|
url: '/',
|
||||||
|
location: 'primary',
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: maxOrder + 10,
|
||||||
|
isVisible: true,
|
||||||
|
isFolder: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 특정 항목의 하위 추가
|
||||||
|
* @param {string} parentId - 부모 id
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const addPrimaryChild = (parentId) => {
|
||||||
|
const pid = String(parentId)
|
||||||
|
const siblings = items.value.filter((i) => i.location === 'primary' && String(i.parentId || '') === pid)
|
||||||
|
const maxOrder = Math.max(0, ...siblings.map((r) => r.sortOrder || 0))
|
||||||
|
items.value.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: '새 하위 메뉴',
|
||||||
|
url: '#',
|
||||||
|
location: 'primary',
|
||||||
|
parentId: pid,
|
||||||
|
sortOrder: maxOrder + 10,
|
||||||
|
isVisible: true,
|
||||||
|
isFolder: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하단 항목 추가
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const addFooterItem = () => {
|
||||||
|
const list = items.value.filter((i) => i.location === 'footer' && !i.parentId)
|
||||||
|
const maxOrder = Math.max(0, ...list.map((r) => r.sortOrder || 0))
|
||||||
|
items.value.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: '',
|
||||||
|
url: '/',
|
||||||
|
location: 'footer',
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: maxOrder + 10,
|
||||||
|
isVisible: true,
|
||||||
|
isFolder: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네비게이션 항목 목록 저장
|
||||||
|
* @returns {Promise<void>} 저장 결과
|
||||||
|
*/
|
||||||
|
const saveNavigation = async () => {
|
||||||
|
if (saving.value || !isNavigationDirty.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
clearToast()
|
||||||
|
showToast('info', '네비게이션을 저장하는 중입니다.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedItems = await $fetch('/admin/api/navigation', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
items: items.value.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
url: item.url,
|
||||||
|
location: item.location,
|
||||||
|
sortOrder: Number(item.sortOrder || 0),
|
||||||
|
isVisible: true,
|
||||||
|
parentId: item.parentId ?? null,
|
||||||
|
isFolder: Boolean(item.isFolder)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = savedItems.map((item) => ({
|
||||||
|
...item,
|
||||||
|
parentId: item.parentId ?? null,
|
||||||
|
isFolder: Boolean(item.isFolder),
|
||||||
|
isVisible: true
|
||||||
|
}))
|
||||||
|
navigationBaseline.value = serializeNavigationItems(items.value)
|
||||||
|
showToast('success', '네비게이션이 저장되었습니다.')
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error?.data?.message || error?.message || '네비게이션을 저장하지 못했습니다.'
|
||||||
|
showToast('error', msg)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-navigation bg-paper p-6">
|
||||||
|
<div class="admin-navigation__header mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="admin-navigation__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Navigation
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-navigation__title mt-2 text-3xl font-semibold">
|
||||||
|
메뉴 관리
|
||||||
|
</h1>
|
||||||
|
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
|
||||||
|
상단·하단을 탭으로 나눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span>와 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving || !isNavigationDirty"
|
||||||
|
@click="saveNavigation"
|
||||||
|
>
|
||||||
|
{{ saving ? '저장 중' : '메뉴 저장' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-navigation__tabs mb-6 flex gap-2 border-b border-line">
|
||||||
|
<button
|
||||||
|
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
|
||||||
|
:class="activeTab === 'primary' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
|
||||||
|
type="button"
|
||||||
|
@click="activeTab = 'primary'"
|
||||||
|
>
|
||||||
|
상단 네비게이션
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
|
||||||
|
:class="activeTab === 'footer' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
|
||||||
|
type="button"
|
||||||
|
@click="activeTab = 'footer'"
|
||||||
|
>
|
||||||
|
하단 네비게이션
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'primary'" class="admin-navigation__panel-primary space-y-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
||||||
|
type="button"
|
||||||
|
@click="addPrimaryRoot"
|
||||||
|
>
|
||||||
|
상단 메뉴 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="primaryTree.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||||
|
상단 메뉴가 없습니다. 위 버튼으로 항목을 추가하세요.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminNavPrimaryBranch
|
||||||
|
v-else
|
||||||
|
:wraps="primaryTree"
|
||||||
|
parent-key="root"
|
||||||
|
:dragging-id="navDraggingId"
|
||||||
|
:drag-over-id="navDragOverId"
|
||||||
|
@drag-start="onPrimaryDragStart"
|
||||||
|
@drag-over="onPrimaryDragOver"
|
||||||
|
@drag-end="onPrimaryDragEnd"
|
||||||
|
@drop="onPrimaryDrop"
|
||||||
|
@add-child="addPrimaryChild"
|
||||||
|
@remove="removeItemCascade"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'footer'" class="admin-navigation__panel-footer space-y-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
||||||
|
type="button"
|
||||||
|
@click="addFooterItem"
|
||||||
|
>
|
||||||
|
하단 메뉴 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="footerItemsSorted.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||||
|
하단 메뉴가 없습니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="admin-navigation__footer-table overflow-hidden rounded border border-line">
|
||||||
|
<table class="admin-navigation__footer-table-inner w-full border-collapse text-left text-sm">
|
||||||
|
<thead class="admin-navigation__footer-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||||
|
<tr>
|
||||||
|
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||||
|
라벨
|
||||||
|
</th>
|
||||||
|
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||||
|
URL
|
||||||
|
</th>
|
||||||
|
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||||
|
관리
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="admin-navigation__footer-body divide-y divide-line bg-white">
|
||||||
|
<tr
|
||||||
|
v-for="(item, index) in footerItemsSorted"
|
||||||
|
:key="item.id"
|
||||||
|
class="admin-navigation__footer-row cursor-move"
|
||||||
|
:class="footerRowClass(item.id)"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onFooterDragStart($event, item.id)"
|
||||||
|
@dragover="onFooterDragOver($event, item.id)"
|
||||||
|
@drop="onFooterDrop($event, item.id)"
|
||||||
|
@dragend="onFooterDragEnd"
|
||||||
|
>
|
||||||
|
<td class="admin-navigation__footer-cell px-4 py-4 text-muted">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</td>
|
||||||
|
<td class="admin-navigation__footer-cell px-4 py-4">
|
||||||
|
<input
|
||||||
|
v-model="item.label"
|
||||||
|
class="w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||||
|
type="text"
|
||||||
|
placeholder="라벨"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="admin-navigation__footer-cell px-4 py-4">
|
||||||
|
<input
|
||||||
|
v-model="item.url"
|
||||||
|
class="w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
|
||||||
|
type="text"
|
||||||
|
placeholder="URL"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="admin-navigation__footer-cell px-4 py-4">
|
||||||
|
<button
|
||||||
|
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
|
||||||
|
type="button"
|
||||||
|
@click="removeItemCascade(item.id)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="toast"
|
||||||
|
class="admin-navigation__toast fixed right-5 top-5 z-[100] max-w-sm rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||||
|
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||||
|
'border-line bg-white text-ink': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
161
pages/admin/pages/[id].vue
Normal file
161
pages/admin/pages/[id].vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const id = computed(() => String(route.params.id || ''))
|
||||||
|
const saving = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const toast = ref(null)
|
||||||
|
let toastTimer = null
|
||||||
|
|
||||||
|
const { data: page } = await useFetch(() => `/admin/api/pages/${id.value}`)
|
||||||
|
|
||||||
|
if (!page.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: '페이지를 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 상태 토스트 표시
|
||||||
|
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||||
|
* @param {string} message - 표시 메시지
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showToast = (type, message) => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
toast.value = { type, message }
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toast.value = null
|
||||||
|
}, 3200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이전 화면에서 전달한 토스트 표시
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showStoredToast = () => {
|
||||||
|
const storedToast = sessionStorage.getItem('SORI_ADMIN_PAGE_TOAST')
|
||||||
|
|
||||||
|
if (!storedToast) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedToast = JSON.parse(storedToast)
|
||||||
|
showToast(parsedToast.type || 'success', parsedToast.message || '저장되었습니다.')
|
||||||
|
} finally {
|
||||||
|
sessionStorage.removeItem('SORI_ADMIN_PAGE_TOAST')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고정 페이지 수정 저장
|
||||||
|
* @param {Object} payload - 페이지 입력값
|
||||||
|
* @returns {Promise<void>} 저장 결과
|
||||||
|
*/
|
||||||
|
const savePage = async (payload) => {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
showToast('info', '변경 내용을 저장하는 중입니다.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedPage = await $fetch(`/admin/api/pages/${id.value}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
page.value = updatedPage
|
||||||
|
showToast('success', '변경 내용이 저장되었습니다.')
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '페이지를 저장하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고정 페이지 삭제
|
||||||
|
* @returns {Promise<void>} 삭제 처리 결과
|
||||||
|
*/
|
||||||
|
const deletePage = async () => {
|
||||||
|
if (!confirm(`"${page.value.title}" 페이지를 삭제할까요?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
showToast('info', '페이지를 삭제하는 중입니다.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/pages/${id.value}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
await navigateTo('/admin/pages')
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '페이지를 삭제하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(showStoredToast)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-page-edit bg-paper p-6">
|
||||||
|
<div class="admin-page-edit__header mb-8 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="admin-page-edit__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Pages
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-page-edit__title mt-2 text-3xl font-semibold">
|
||||||
|
페이지 수정
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="admin-page-edit__actions flex gap-2">
|
||||||
|
<NuxtLink
|
||||||
|
class="admin-page-edit__view rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
||||||
|
:to="`/pages/${page.slug}`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
보기
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
class="admin-page-edit__delete rounded border border-red-200 bg-white px-4 py-2 text-sm font-semibold text-red-700 disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="deletePage"
|
||||||
|
>
|
||||||
|
{{ deleting ? '삭제 중' : '삭제' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="errorMessage" class="admin-page-edit__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<AdminPageForm :initial-page="page" submit-label="변경 저장" :saving="saving" @submit="savePage" />
|
||||||
|
<div
|
||||||
|
v-if="toast"
|
||||||
|
class="admin-page-edit__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||||
|
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||||
|
'border-line bg-white text-ink': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
117
pages/admin/pages/index.vue
Normal file
117
pages/admin/pages/index.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const deletingId = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const { data: pages, refresh } = await useFetch('/admin/api/pages', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 화면 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고정 페이지 삭제
|
||||||
|
* @param {Object} page - 삭제할 고정 페이지
|
||||||
|
* @returns {Promise<void>} 삭제 처리 결과
|
||||||
|
*/
|
||||||
|
const deletePage = async (page) => {
|
||||||
|
if (!confirm(`"${page.title}" 페이지를 삭제할까요?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingId.value = page.id
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/pages/${page.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '페이지를 삭제하지 못했습니다.'
|
||||||
|
} finally {
|
||||||
|
deletingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-pages bg-paper p-6">
|
||||||
|
<div class="admin-pages__header flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="admin-pages__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Pages
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-pages__title mt-2 text-3xl font-semibold">
|
||||||
|
페이지 목록
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<NuxtLink class="admin-pages__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/pages/new">
|
||||||
|
새 페이지
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="admin-pages__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="admin-pages__table mt-8 overflow-hidden border border-line">
|
||||||
|
<table class="admin-pages__table-inner w-full border-collapse text-left text-sm">
|
||||||
|
<thead class="admin-pages__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||||
|
<tr>
|
||||||
|
<th class="admin-pages__cell px-4 py-3">제목</th>
|
||||||
|
<th class="admin-pages__cell px-4 py-3">수정일</th>
|
||||||
|
<th class="admin-pages__cell px-4 py-3">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="admin-pages__table-body divide-y divide-line bg-white">
|
||||||
|
<tr v-for="page in pages" :key="page.id" class="admin-pages__row">
|
||||||
|
<td class="admin-pages__cell px-4 py-4">
|
||||||
|
<NuxtLink class="admin-pages__title-link font-semibold hover:opacity-70" :to="`/admin/pages/${page.id}`">
|
||||||
|
{{ page.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<p class="admin-pages__slug mt-1 text-xs text-muted">
|
||||||
|
/pages/{{ page.slug }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="admin-pages__cell px-4 py-4">
|
||||||
|
{{ formatDate(page.updatedAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="admin-pages__cell px-4 py-4">
|
||||||
|
<button
|
||||||
|
class="admin-pages__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="deletingId === page.id"
|
||||||
|
@click="deletePage(page)"
|
||||||
|
>
|
||||||
|
{{ deletingId === page.id ? '삭제 중' : '삭제' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p v-if="pages.length === 0" class="admin-pages__empty mt-6 text-sm text-muted">
|
||||||
|
아직 작성된 페이지가 없습니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
86
pages/admin/pages/new.vue
Normal file
86
pages/admin/pages/new.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const toast = ref(null)
|
||||||
|
let toastTimer = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 상태 토스트 표시
|
||||||
|
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||||
|
* @param {string} message - 표시 메시지
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showToast = (type, message) => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
toast.value = { type, message }
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toast.value = null
|
||||||
|
}, 3200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 고정 페이지 저장
|
||||||
|
* @param {Object} payload - 페이지 입력값
|
||||||
|
* @returns {Promise<void>} 저장 결과
|
||||||
|
*/
|
||||||
|
const savePage = async (payload) => {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
showToast('info', '페이지를 저장하는 중입니다.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await $fetch('/admin/api/pages', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
sessionStorage.setItem('SORI_ADMIN_PAGE_TOAST', JSON.stringify({
|
||||||
|
type: 'success',
|
||||||
|
message: '페이지가 저장되었습니다.'
|
||||||
|
}))
|
||||||
|
await navigateTo(`/admin/pages/${page.id}`)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '페이지를 저장하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-page-editor bg-paper p-6">
|
||||||
|
<div class="admin-page-editor__header mb-8">
|
||||||
|
<p class="admin-page-editor__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Pages
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-page-editor__title mt-2 text-3xl font-semibold">
|
||||||
|
새 페이지 작성
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p v-if="errorMessage" class="admin-page-editor__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<AdminPageForm submit-label="페이지 저장" :saving="saving" @submit="savePage" />
|
||||||
|
<div
|
||||||
|
v-if="toast"
|
||||||
|
class="admin-page-editor__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||||
|
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||||
|
'border-line bg-white text-ink': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
174
pages/admin/posts/[id].vue
Normal file
174
pages/admin/posts/[id].vue
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const id = computed(() => String(route.params.id || ''))
|
||||||
|
const saving = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const postForm = ref(null)
|
||||||
|
const toast = ref(null)
|
||||||
|
let toastTimer = null
|
||||||
|
|
||||||
|
const { data: post } = await useFetch(() => `/admin/api/posts/${id.value}`)
|
||||||
|
|
||||||
|
if (!post.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: '게시물을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 화면에서 접근 가능한 게시물 여부 확인
|
||||||
|
* @param {Object} value - 게시물
|
||||||
|
* @returns {boolean} 공개 접근 가능 여부
|
||||||
|
*/
|
||||||
|
const isPublicPost = (value) => value?.status === 'published'
|
||||||
|
&& (!value.publishedAt || new Date(value.publishedAt) <= new Date())
|
||||||
|
|
||||||
|
const publicPostUrl = computed(() => post.value?.slug ? `/post/${post.value.slug}` : '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 상태 토스트 표시
|
||||||
|
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||||
|
* @param {string} message - 표시 메시지
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showToast = (type, message) => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
toast.value = { type, message }
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toast.value = null
|
||||||
|
}, 3200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이전 화면에서 전달한 토스트 표시
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showStoredToast = () => {
|
||||||
|
const storedToast = sessionStorage.getItem('SORI_ADMIN_POST_TOAST')
|
||||||
|
|
||||||
|
if (!storedToast) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedToast = JSON.parse(storedToast)
|
||||||
|
showToast(parsedToast.type || 'success', parsedToast.message || '저장되었습니다.')
|
||||||
|
} finally {
|
||||||
|
sessionStorage.removeItem('SORI_ADMIN_POST_TOAST')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 미리보기
|
||||||
|
* @param {Object} payload - 게시물 입력값
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const previewPost = (payload) => {
|
||||||
|
localStorage.setItem('SORI_ADMIN_POST_PREVIEW', JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
id: id.value,
|
||||||
|
previewedAt: new Date().toISOString()
|
||||||
|
}))
|
||||||
|
window.open('/admin/posts/preview', '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 수정 저장
|
||||||
|
* @param {Object} payload - 게시물 입력값
|
||||||
|
* @returns {Promise<void>} 저장 결과
|
||||||
|
*/
|
||||||
|
const savePost = async (payload) => {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
showToast('info', '변경 내용을 저장하는 중입니다.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedPost = await $fetch(`/admin/api/posts/${id.value}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
post.value = updatedPost
|
||||||
|
postForm.value?.markSaved()
|
||||||
|
postForm.value?.clearAutosave()
|
||||||
|
showToast('success', '변경 내용이 저장되었습니다.')
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 삭제
|
||||||
|
* @returns {Promise<void>} 삭제 처리 결과
|
||||||
|
*/
|
||||||
|
const deletePost = async () => {
|
||||||
|
if (!confirm(`"${post.value.title}" 글을 삭제할까요?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
showToast('info', '글을 삭제하는 중입니다.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/posts/${id.value}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
postForm.value?.allowNextRouteLeave()
|
||||||
|
await navigateTo('/admin/posts')
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(showStoredToast)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-post-edit relative bg-white">
|
||||||
|
<p v-if="errorMessage" class="admin-post-edit__error mx-6 mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<AdminPostForm
|
||||||
|
ref="postForm"
|
||||||
|
:initial-post="post"
|
||||||
|
submit-label="변경 저장"
|
||||||
|
:saving="saving"
|
||||||
|
:can-view-post="isPublicPost(post)"
|
||||||
|
:public-url="publicPostUrl"
|
||||||
|
:deleting="deleting"
|
||||||
|
show-delete
|
||||||
|
@submit="savePost"
|
||||||
|
@preview="previewPost"
|
||||||
|
@delete="deletePost"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="toast"
|
||||||
|
class="admin-post-edit__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||||
|
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||||
|
'border-line bg-white text-ink': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
187
pages/admin/posts/index.vue
Normal file
187
pages/admin/posts/index.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const deletingId = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 화면 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 공개 여부 확인
|
||||||
|
* @param {Object} post - 게시물
|
||||||
|
* @returns {boolean} 공개 여부
|
||||||
|
*/
|
||||||
|
const isPublicPost = (post) => post.status === 'published'
|
||||||
|
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 상태 표시 문자열 생성
|
||||||
|
* @param {Object} post - 게시물
|
||||||
|
* @returns {string} 상태 표시 문자열
|
||||||
|
*/
|
||||||
|
const getPostStatusLabel = (post) => {
|
||||||
|
if (post.status === 'published' && !isPublicPost(post)) {
|
||||||
|
return '예약'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.status === 'published') {
|
||||||
|
return '발행'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.status === 'private') {
|
||||||
|
return '비공개'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '초안'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 삭제
|
||||||
|
* @param {Object} post - 삭제할 게시물
|
||||||
|
* @returns {Promise<void>} 삭제 처리 결과
|
||||||
|
*/
|
||||||
|
const deletePost = async (post) => {
|
||||||
|
if (!confirm(`"${post.title}" 글을 삭제할까요?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingId.value = post.id
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/posts/${post.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
||||||
|
} finally {
|
||||||
|
deletingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-posts bg-paper p-6">
|
||||||
|
<div class="admin-posts__header flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="admin-posts__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Posts
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-posts__title mt-2 text-3xl font-semibold">
|
||||||
|
글 목록
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<NuxtLink class="admin-posts__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/posts/new">
|
||||||
|
새 글
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="admin-posts__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="admin-posts__table mt-8 overflow-hidden border border-line">
|
||||||
|
<table class="admin-posts__table-inner w-full border-collapse text-left text-sm">
|
||||||
|
<thead class="admin-posts__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||||
|
<tr>
|
||||||
|
<th class="admin-posts__cell px-4 py-3">제목</th>
|
||||||
|
<th class="admin-posts__cell px-4 py-3">상태</th>
|
||||||
|
<th class="admin-posts__cell px-4 py-3">태그</th>
|
||||||
|
<th class="admin-posts__cell px-4 py-3">수정일</th>
|
||||||
|
<th class="admin-posts__cell px-4 py-3">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
|
||||||
|
<tr v-for="post in posts" :key="post.id" class="admin-posts__row">
|
||||||
|
<td class="admin-posts__cell px-4 py-4">
|
||||||
|
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
|
||||||
|
{{ post.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<p class="admin-posts__slug mt-1 text-xs text-muted">
|
||||||
|
/post/{{ post.slug }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="admin-posts__cell px-4 py-4">
|
||||||
|
<span
|
||||||
|
class="admin-posts__status rounded px-2 py-1 text-xs font-semibold"
|
||||||
|
:class="{
|
||||||
|
'bg-green-50 text-green-700': getPostStatusLabel(post) === '발행',
|
||||||
|
'bg-blue-50 text-blue-700': getPostStatusLabel(post) === '예약',
|
||||||
|
'bg-[#f5f5f2] text-muted': getPostStatusLabel(post) === '초안',
|
||||||
|
'bg-red-50 text-red-700': getPostStatusLabel(post) === '비공개'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ getPostStatusLabel(post) }}
|
||||||
|
</span>
|
||||||
|
<p v-if="post.publishedAt" class="admin-posts__published-at mt-1 text-xs text-muted">
|
||||||
|
{{ formatDate(post.publishedAt) }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="admin-posts__cell px-4 py-4">
|
||||||
|
<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] bg-[#ecd2de] px-2 text-xs font-semibold text-[#e04e87]"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="admin-posts__tag-empty text-muted">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="admin-posts__cell px-4 py-4">
|
||||||
|
{{ formatDate(post.updatedAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="admin-posts__cell px-4 py-4">
|
||||||
|
<button
|
||||||
|
class="admin-posts__delete-icon inline-flex size-9 items-center justify-center rounded text-muted opacity-35 transition-all hover:opacity-100 hover:text-red-600 focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-20"
|
||||||
|
type="button"
|
||||||
|
:disabled="deletingId === post.id"
|
||||||
|
:aria-label="deletingId === post.id ? '삭제 중' : '삭제'"
|
||||||
|
@click="deletePost(post)"
|
||||||
|
>
|
||||||
|
<span v-if="deletingId === post.id" class="admin-posts__delete-progress text-[10px] font-semibold text-muted" aria-hidden="true">…</span>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="size-5 shrink-0"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p v-if="posts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
|
||||||
|
아직 작성된 글이 없습니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
95
pages/admin/posts/new.vue
Normal file
95
pages/admin/posts/new.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const postForm = ref(null)
|
||||||
|
const toast = ref(null)
|
||||||
|
let toastTimer = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 상태 토스트 표시
|
||||||
|
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||||
|
* @param {string} message - 표시 메시지
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showToast = (type, message) => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
toast.value = { type, message }
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toast.value = null
|
||||||
|
}, 3200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 게시물 미리보기
|
||||||
|
* @param {Object} payload - 게시물 입력값
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const previewPost = (payload) => {
|
||||||
|
localStorage.setItem('SORI_ADMIN_POST_PREVIEW', JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
previewedAt: new Date().toISOString()
|
||||||
|
}))
|
||||||
|
window.open('/admin/posts/preview', '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 게시물 저장
|
||||||
|
* @param {Object} payload - 게시물 입력값
|
||||||
|
* @returns {Promise<void>} 저장 결과
|
||||||
|
*/
|
||||||
|
const savePost = async (payload) => {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
showToast('info', '글을 저장하는 중입니다.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await $fetch('/admin/api/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
postForm.value?.markSaved()
|
||||||
|
postForm.value?.clearAutosave()
|
||||||
|
postForm.value?.allowNextRouteLeave()
|
||||||
|
sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({
|
||||||
|
type: 'success',
|
||||||
|
message: '글이 저장되었습니다.'
|
||||||
|
}))
|
||||||
|
await navigateTo(`/admin/posts/${post.id}`)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-post-editor bg-white">
|
||||||
|
<p v-if="errorMessage" class="admin-post-editor__error mx-6 mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<AdminPostForm ref="postForm" submit-label="글 저장" :saving="saving" @submit="savePost" @preview="previewPost" />
|
||||||
|
<div
|
||||||
|
v-if="toast"
|
||||||
|
class="admin-post-editor__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||||
|
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||||
|
'border-line bg-white text-ink': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user