Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59a50a0c97 | |||
| b4e4e37f5a | |||
| 536ee7079e | |||
| 9e544d97fa | |||
| 20b901d4a1 | |||
| 0ed848a2eb | |||
| 08f0aa0efa | |||
| 17dcd04339 | |||
| 36625de1eb | |||
| 62e501f8d0 | |||
| 849e86802f | |||
| 5da93b9aa4 | |||
| 113c974ee5 | |||
| f5bfb560e2 | |||
| 941355cae9 | |||
| 91b7369a07 | |||
| 5eb6c88381 | |||
| eab81697e5 | |||
| 88a0860078 | |||
| 35c378c8f5 | |||
| bd0e2ad120 | |||
| 1b035de16c | |||
| 4862b52b3a | |||
| 6367e62ef0 | |||
| 1487e9da76 | |||
| 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
|
||||
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# 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
|
||||
DOCKER_SUBNET=10.250.50.0/24
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules/
|
||||
.output/
|
||||
.nuxt/
|
||||
dist/
|
||||
public/uploads/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -26,4 +27,8 @@ Thumbs.db
|
||||
npm-debug.log*
|
||||
|
||||
# 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` 접두사를 포함해 순차 증가시킨다.
|
||||
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
||||
- 커밋까지 완료한 작업은 사용자가 중단을 요청하지 않는 한 `git push`로 원격에 반영해 푸시 누락을 피한다.
|
||||
|
||||
민감 정보 예시:
|
||||
- 실명
|
||||
@@ -157,6 +158,7 @@
|
||||
- 기존 API 호출 패턴을 따른다.
|
||||
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
|
||||
- 하드코딩된 값 사용을 금지한다.
|
||||
- 초기 백엔드는 별도 앱으로 분리하지 않고 Nuxt `server/api`를 사용한다.
|
||||
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
|
||||
- 운영 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>
|
||||
259
assets/css/main.css
Normal file
259
assets/css/main.css
Normal file
@@ -0,0 +1,259 @@
|
||||
@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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 등 행 호버 — 라이트 테마에서 밝은 크림 톤, 다크는 패널 대비 유지
|
||||
*/
|
||||
.site-sidebar-nav-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.site-sidebar-nav-row:hover {
|
||||
background-color: #f7f4ef;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .site-sidebar-nav-row:hover {
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) .site-sidebar-nav-row: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
1287
components/admin/AdminMarkdownEditor.vue
Normal file
1287
components/admin/AdminMarkdownEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
657
components/admin/AdminMemberForm.vue
Normal file
657
components/admin/AdminMemberForm.vue
Normal file
@@ -0,0 +1,657 @@
|
||||
<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('file', file)
|
||||
const result = isNewMember.value
|
||||
? await $fetch('/admin/api/member-avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
: await $fetch(`/admin/api/members/${props.member.id}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.avatarUrl = result.avatarUrl || ''
|
||||
|
||||
if (!isNewMember.value) {
|
||||
emit('saved', result)
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
saveMessage.value = '썸네일이 변경되었습니다.'
|
||||
}
|
||||
} 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>
|
||||
1338
components/admin/AdminPostForm.vue
Normal file
1338
components/admin/AdminPostForm.vue
Normal file
File diff suppressed because it is too large
Load Diff
141
components/admin/AdminTagForm.vue
Normal file
141
components/admin/AdminTagForm.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
initialTag: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
default: '저장'
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
defaultTagType: {
|
||||
type: String,
|
||||
default: 'general'
|
||||
}
|
||||
})
|
||||
|
||||
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 || props.defaultTagType
|
||||
})
|
||||
}
|
||||
</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>
|
||||
|
||||
693
components/content/ContentMarkdownRenderer.vue
Normal file
693
components/content/ContentMarkdownRenderer.vue
Normal file
@@ -0,0 +1,693 @@
|
||||
<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 {string} line - 마크다운 행
|
||||
* @returns {boolean} 블록 시작 여부
|
||||
*/
|
||||
const isMarkdownBlockStart = (line) => {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
return trimmedLine === BLANK_PARAGRAPH_MARKER ||
|
||||
trimmedLine === '>>>' ||
|
||||
trimmedLine === ':::bookmark' ||
|
||||
trimmedLine === ':::signup' ||
|
||||
trimmedLine === ':::gallery' ||
|
||||
trimmedLine === ':::embed' ||
|
||||
trimmedLine.startsWith(':::callout') ||
|
||||
trimmedLine.startsWith(':::toggle') ||
|
||||
trimmedLine.startsWith('```') ||
|
||||
trimmedLine === '---' ||
|
||||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
|
||||
trimmedLine.startsWith('> ') ||
|
||||
/^- /.test(trimmedLine) ||
|
||||
/^\d+\.\s+/.test(trimmedLine) ||
|
||||
Boolean(parseImageLine(trimmedLine))
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 hard break 표식이 있는 행인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean} hard break 여부
|
||||
*/
|
||||
const hasMarkdownHardBreak = (line) => /( {2,}|\\)$/.test(line)
|
||||
|
||||
/**
|
||||
* 문단 행에서 hard break 표식을 제거한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {string} 정리된 문단 행
|
||||
*/
|
||||
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
|
||||
|
||||
/**
|
||||
* 빈 줄 공백 블록 높이를 반환한다.
|
||||
* @param {Object} block - 렌더링 블록
|
||||
* @returns {string} Tailwind 높이 클래스
|
||||
*/
|
||||
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
|
||||
|
||||
/**
|
||||
* 닫힘 표식까지의 행 목록을 반환
|
||||
* @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('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!trimmedLine) {
|
||||
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
|
||||
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
|
||||
}
|
||||
|
||||
const paragraphLines = [cleanParagraphLine(line)]
|
||||
let shouldJoinNextLine = hasMarkdownHardBreak(line)
|
||||
index += 1
|
||||
|
||||
while (shouldJoinNextLine && index < lines.length) {
|
||||
const nextLine = lines[index]
|
||||
const nextTrimmedLine = nextLine.trim()
|
||||
|
||||
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
|
||||
break
|
||||
}
|
||||
|
||||
paragraphLines.push(cleanParagraphLine(nextLine))
|
||||
shouldJoinNextLine = hasMarkdownHardBreak(nextLine)
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`))
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||
|
||||
/**
|
||||
* 인라인 마크다운을 표시 세그먼트로 변환한다.
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트
|
||||
*/
|
||||
const parseInlineSegments = (value) => {
|
||||
const source = String(value || '')
|
||||
const segments = []
|
||||
const pattern = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
|
||||
let lastIndex = 0
|
||||
let match = pattern.exec(source)
|
||||
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
text: source.slice(lastIndex, match.index)
|
||||
})
|
||||
}
|
||||
|
||||
if (match[2] && match[3]) {
|
||||
segments.push({
|
||||
type: 'link',
|
||||
text: match[2],
|
||||
href: match[3]
|
||||
})
|
||||
} else if (match[4]) {
|
||||
segments.push({
|
||||
type: 'strong',
|
||||
text: match[4]
|
||||
})
|
||||
} else if (match[5]) {
|
||||
segments.push({
|
||||
type: 'code',
|
||||
text: match[5]
|
||||
})
|
||||
} else if (match[6]) {
|
||||
segments.push({
|
||||
type: 'em',
|
||||
text: match[6]
|
||||
})
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex
|
||||
match = pattern.exec(source)
|
||||
}
|
||||
|
||||
if (lastIndex < source.length) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
text: source.slice(lastIndex)
|
||||
})
|
||||
}
|
||||
|
||||
return segments.length ? segments : [{ type: 'text', text: source }]
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {Array<Array<{ type: string, text: string, href?: string }>>} 줄별 인라인 세그먼트
|
||||
*/
|
||||
const parseInlineSegmentLines = (value) => {
|
||||
return String(value || '').split('\n').map(parseInlineSegments)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스를 연다
|
||||
* @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">
|
||||
<div v-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
|
||||
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseHeading>
|
||||
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseBlockquote>
|
||||
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
|
||||
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</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"
|
||||
>
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-black/5 px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseCallout>
|
||||
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</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 mb-2.5 text-base text-[var(--site-text)] last:mb-0">
|
||||
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
|
||||
<br v-if="lineIndex > 0">
|
||||
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</template>
|
||||
</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 mb-2.5 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-sidebar-nav-row 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-sidebar-nav-row 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 }}<template v-if="post.tag"> / {{ post.tag }}</template>
|
||||
</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-sidebar-nav-row, flex, 패딩 전환(가로 전체 호버 배경) */
|
||||
const navRowShell =
|
||||
'site-sidebar-nav-row 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 '';
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
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
|
||||
networks:
|
||||
- sori-studio-network
|
||||
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
|
||||
# NAS 등: 호스트 db/migrations 가 다른 UID만 읽을 수 있으면 컨테이너에서 Permission denied → DB 재시작 루프. 프로젝트 루트에서 chmod -R a+rX db/migrations 및 상위 경로 통과 권한 확인.
|
||||
- ./db/migrations:/docker-entrypoint-initdb.d:ro
|
||||
networks:
|
||||
- sori-studio-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
sori-studio-postgres:
|
||||
|
||||
networks:
|
||||
sori-studio-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${DOCKER_SUBNET:-10.250.50.0/24}
|
||||
@@ -1,5 +1,133 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.1.8
|
||||
|
||||
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.
|
||||
- 태그 추가 버튼을 일반 태그 영역으로 옮기고, 메인 태그 순서는 드래그 후 자동 저장되도록 개선.
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.
|
||||
- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
|
||||
|
||||
## v1.1.5
|
||||
|
||||
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
|
||||
|
||||
## v1.1.4
|
||||
|
||||
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||
- 관리자 계정과 일반 회원 모두 같은 회원 썸네일 저장 규칙(WebP 변환, 1:1 크롭)을 쓰도록 정리.
|
||||
- 태그 목록 카드 그리드 여백 수정 반영.
|
||||
|
||||
## v1.0.19
|
||||
|
||||
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
|
||||
- 기존 공백 2개 hard break도 계속 렌더링되도록 호환 처리.
|
||||
|
||||
## v1.0.18
|
||||
|
||||
- 여러 줄을 비워둔 경우 미리보기와 공개 본문에서도 비운 만큼 공백이 보이도록 보강.
|
||||
- 미리보기 모드에서 편집 툴바와 카드형 패널 외곽을 숨겨 본문만 보이게 정리.
|
||||
- 줄 번호 영역의 스크롤바를 숨겨 작성 화면을 더 차분하게 정리.
|
||||
|
||||
## v1.0.17
|
||||
|
||||
- 글쓰기 영역의 보더와 카드형 배경을 제거해 본문 편집 화면을 더 가볍게 정리.
|
||||
- 줄 번호를 본문 바깥에 띄우고 현재 줄 액센트 배경을 제거.
|
||||
- Enter는 한 줄만 내려가는 새 문단으로, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 조정.
|
||||
- 문단과 제목 아래 기본 간격을 10px 기준으로 정리.
|
||||
|
||||
## v1.0.16
|
||||
|
||||
- 글쓰기에서 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 정리.
|
||||
- 미리보기 전환 후 작성 모드로 돌아오면 기존 커서 위치에서 계속 입력할 수 있도록 개선.
|
||||
- 공개 본문과 관리자 미리보기의 문단 간격을 24px 기준으로 통일.
|
||||
|
||||
## v1.0.15
|
||||
|
||||
- 본문 중간의 빈 줄이 공개 화면과 관리자 미리보기에서 사라지지 않도록 간격 보존을 보강.
|
||||
|
||||
## v1.0.14
|
||||
|
||||
- Markdown-first 전환 후 레거시 블록 본문이나 기존 자동 저장본 때문에 게시물 발행이 막히는 문제를 보강.
|
||||
|
||||
## v1.0.13
|
||||
|
||||
- 관리자 글쓰기에서 외부 웹 글 붙여넣기를 기본 마크다운으로 정리하고, 커서가 위치한 이미지·갤러리 블록을 바로 편집할 수 있도록 개선.
|
||||
|
||||
## v1.0.11
|
||||
|
||||
- 관리자 글쓰기 본문을 Markdown-first 에디터로 교체해 범위 선택, 복사/붙여넣기, 미디어 이미지·갤러리 삽입 흐름을 단순화.
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- Docker 운영 컨테이너가 빌드 시점 설정 대신 `.env.production`의 런타임 환경 변수를 우선 읽도록 보강.
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- owner/admin 계정이 없는 운영 DB에서도 환경 변수 관리자 계정으로 첫 owner를 생성하거나 기존 일반 회원을 승격할 수 있도록 보강.
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- NAS에서 Postgres 초기 마이그레이션 디렉터리 권한 문제로 DB 컨테이너가 재시작될 때 확인할 배포 절차를 정리.
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강.
|
||||
- 배포 문서의 운영 환경 변수 생성 안내를 정리.
|
||||
|
||||
## v1.0.1
|
||||
|
||||
- Docker Compose 네트워크 충돌 대응을 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
|
||||
|
||||
## 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
|
||||
|
||||
- sori.studio 개인 블로그/CMS 초기 방향 정리.
|
||||
|
||||
@@ -19,14 +19,19 @@
|
||||
- Vue 컴포넌트 파일: PascalCase
|
||||
- CSS 클래스: kebab-case
|
||||
- 고유 클래스명 필수 (Tailwind 외)
|
||||
- Nuxt 컴포넌트 자동 import는 경로 prefix 없이 파일명 기준으로 사용
|
||||
|
||||
## 스타일
|
||||
|
||||
- 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`가 중복 주입될 수 있음).
|
||||
- 관리자 글 에디터는 Markdown-first textarea 편집을 기준으로 하며 저장 값은 기존 마크다운 문자열을 유지
|
||||
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast`의 `showToast`로 우측 상단(`z-[100]`)에 표시한다.
|
||||
- 관리자 메뉴 관리는 상단/하단 탭으로 나누고, 순서는 드래그로 조정한다(상단은 동일 부모의 형제끼리만).
|
||||
|
||||
```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 운영 설정은 별도 환경 파일로 분리
|
||||
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
|
||||
- 운영 환경에서는 `DATABASE_URL`과 `MEMBER_SESSION_SECRET` 누락을 허용하지 않음
|
||||
|
||||
## 검증
|
||||
|
||||
- `npm run lint`: JavaScript 파일 문법 점검
|
||||
- `npm run test`: Nuxt 프로덕션 빌드 기반 회귀 검증
|
||||
- `npm run verify`: 문법 점검과 빌드를 함께 실행
|
||||
|
||||
263
docs/deploy.md
263
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 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+
|
||||
- 개발 DB
|
||||
|
||||
@@ -32,23 +35,77 @@ cd sori.studio
|
||||
npm install
|
||||
|
||||
# 개발 환경 변수 설정
|
||||
# .env.development는 Git에 올리지 않는 로컬 전용 파일
|
||||
# 새로 만들 때는 .env.example을 복사한 뒤 비밀번호를 랜덤 값으로 교체
|
||||
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
|
||||
```
|
||||
|
||||
### 로컬 개발 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://localhost:3000/admin
|
||||
- 개발 서버: http://127.0.0.1:43117
|
||||
- 관리자: 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 배포
|
||||
|
||||
> Dockerfile과 docker-compose 설정은 아직 작성 전이다.
|
||||
> Dockerfile과 docker-compose 설정은 초안이며 NAS 운영 환경에서는 아직 검증 전이다.
|
||||
|
||||
### SSH 접속
|
||||
|
||||
@@ -60,7 +117,7 @@ ssh [NAS_IP]
|
||||
|
||||
```bash
|
||||
# 프로젝트 디렉토리로 이동
|
||||
cd /volume1/docker/sori.studio
|
||||
cd /volume1/docker/projects/apps/
|
||||
|
||||
# 프로젝트 클론
|
||||
git clone https://git.sori.studio/zenn/sori.studio.git
|
||||
@@ -69,27 +126,34 @@ git clone https://git.sori.studio/zenn/sori.studio.git
|
||||
cd sori.studio
|
||||
|
||||
# 운영 환경 변수 설정
|
||||
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
||||
cp .env.example .env.production
|
||||
# .env.production 파일에 운영 DB 연결 정보 입력
|
||||
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
||||
# 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨
|
||||
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
||||
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
|
||||
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
||||
|
||||
# Docker 빌드 및 실행
|
||||
docker-compose up -d
|
||||
docker compose --env-file .env.production up -d --build
|
||||
```
|
||||
|
||||
### 프로덕션 빌드 (NAS에서)
|
||||
### Docker 네트워크 충돌 대응
|
||||
|
||||
NAS에 Docker 컨테이너가 많이 실행 중이면 `could not find an available, non-overlapping IPv4 address pool` 오류가 날 수 있다. 이 프로젝트는 기본 `DOCKER_SUBNET=10.250.50.0/24`를 사용한다. 해당 대역도 NAS 내부망 또는 다른 Docker 네트워크와 겹치면 `.env.production`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다.
|
||||
|
||||
```bash
|
||||
# 프로덕션 빌드
|
||||
npm run build
|
||||
|
||||
# 또는 Docker로 빌드
|
||||
docker build -t sori.studio:latest .
|
||||
docker run -d -p 3000:3000 sori.studio:latest
|
||||
DOCKER_SUBNET=10.250.51.0/24
|
||||
docker compose --env-file .env.production up -d --build
|
||||
```
|
||||
|
||||
### 포트
|
||||
|
||||
- HTTP: 3000
|
||||
- 로컬 개발: 43117
|
||||
- NAS Docker 외부: 43118
|
||||
- 컨테이너 내부: 3000
|
||||
- PostgreSQL 외부: 43119
|
||||
- Docker 내부 네트워크 기본값: `10.250.50.0/24`
|
||||
- HTTPS: 3001 (SSL 설정 시)
|
||||
|
||||
---
|
||||
@@ -98,13 +162,176 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
|
||||
- 로컬 개발: `.env.development`의 `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 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||
- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB에 owner/admin이 없는 최초 관리자 생성에만 사용한다. 같은 이메일의 일반 회원이 이미 있으면 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준으로 갱신한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다.
|
||||
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
|
||||
- Docker 운영 컨테이너는 `.env.production`의 서버 환경 변수를 런타임 `process.env`에서 우선 읽는다.
|
||||
|
||||
### 이메일 인증(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 등으로 추후 연결 가능하게 설계
|
||||
- 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 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음
|
||||
|
||||
### 컨테이너가 `Restarting`일 때
|
||||
|
||||
`Error response from daemon: Container … is restarting, wait until the container is running`은 **프로세스가 곧바로 종료**되어 `restart: unless-stopped`가 반복 시도하는 상태다. 원인은 로그에 나온다.
|
||||
|
||||
1. **어느 서비스인지 확인** (`docker-compose.yml` 기준 이름은 `sori-studio`, `sori-studio-db`).
|
||||
|
||||
```bash
|
||||
docker ps -a --filter "name=sori-studio"
|
||||
```
|
||||
|
||||
2. **해당 컨테이너 로그** (가장 중요).
|
||||
|
||||
```bash
|
||||
docker logs sori-studio --tail 150
|
||||
docker logs sori-studio-db --tail 150
|
||||
```
|
||||
|
||||
Compose로 올렸다면:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production logs sori-studio --tail 200
|
||||
docker compose --env-file .env.production logs sori-studio-db --tail 200
|
||||
```
|
||||
|
||||
3. **자주 나오는 원인**
|
||||
|
||||
- **`sori-studio`**: `DATABASE_URL` 누락·오타, `MEMBER_SESSION_SECRET` 미설정, DB 호스트가 컨테이너 기준으로 잘못됨(예: 앱은 Docker 안인데 URL만 `127.0.0.1`로 DB를 가리킴), 애플리케이션 예외로 즉시 종료.
|
||||
- **`sori-studio-db`**: 이미 초기화된 볼륨과 다른 `POSTGRES_PASSWORD`로 다시 올린 경우, `docker-entrypoint-initdb.d` 마이그레이션 SQL 오류, 디스크/권한 문제.
|
||||
- **`sori-studio-db` 로그에 `ls: can't open '/docker-entrypoint-initdb.d/': Permission denied`**: 아래 **NAS·호스트에서 `db/migrations` 권한** 절차를 확인한다.
|
||||
|
||||
4. 로그를 고친 뒤에는 `docker compose --env-file .env.production up -d`로 다시 올리고, `docker ps`에서 `Up` 상태인지 확인한다.
|
||||
|
||||
### NAS·호스트에서 `db/migrations` 권한
|
||||
|
||||
`docker-compose.yml`은 `./db/migrations`를 Postgres 이미지의 `/docker-entrypoint-initdb.d`에 **읽기 전용**으로 붙인다. 공식 엔트리포인트는 이 디렉터리를 `ls`로 읽는데, NAS(UGREEN 등)나 SSH로 복사한 트리에서 **폴더·파일이 700/600만 허용**이거나 **상위 디렉터리에 실행(x) 비트가 없으면** 컨테이너 안 `postgres` 사용자가 경로를 통과하지 못해 `Permission denied`가 반복되고 DB 컨테이너가 재시작 루프에 들어갈 수 있다.
|
||||
|
||||
프로젝트 루트( `docker compose` 를 실행하는 디렉터리)에서 SSH로 다음을 적용한다. **비밀번호는 바꾸지 않으며**, 읽기·디렉터리 통과만 연다.
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/projects/apps/sori.studio
|
||||
# 마이그레이션 디렉터리와 그 안 SQL: 모두 읽기, 디렉터리는 검색 가능
|
||||
sudo chmod -R a+rX db/migrations
|
||||
# 상위 db/, 프로젝트 루트가 다른 사용자만 rwx 인 경우 통과 허용
|
||||
sudo chmod a+x . db db/migrations
|
||||
```
|
||||
|
||||
그다음 DB 컨테이너만 재시작한다.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production restart sori-studio-db
|
||||
```
|
||||
|
||||
여전히 동일하면 프로젝트가 **SMB 공유 폴더 위**에만 있지 않은지 확인한다. Docker 데몬이 네이티브 경로(ext4 등)의 디렉터리를 마운트할 때 권한이 더 예측 가능하다.
|
||||
|
||||
## 업로드 파일
|
||||
|
||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||
- NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
|
||||
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
|
||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
## 사용자 액션 필요 항목
|
||||
|
||||
- NAS SSH 접속 주소 확인.
|
||||
- NAS 프로젝트 루트 경로 확정.
|
||||
- 운영 DB 이름, 계정, 권한 확정.
|
||||
- 운영 업로드 볼륨 경로 확정.
|
||||
- 도메인 `sori.studio`의 NAS 연결 방식 확정.
|
||||
|
||||
987
docs/history.md
987
docs/history.md
@@ -1,5 +1,992 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-15 v1.1.8
|
||||
|
||||
### 태그 순서 저장을 드롭 즉시 자동화
|
||||
|
||||
메인 태그 정렬은 드래그 자체가 명확한 저장 의도를 가진 조작이므로 별도의 `정렬 저장` 버튼을 두면 화면의 책임이 나뉘어 보인다. 태그 추가 버튼도 화면 전체 제목 옆에 있으면 메인 태그 추가처럼 보일 수 있어, 새 태그가 기본적으로 일반 태그로 생성되는 현재 구조에 맞춰 일반 태그 섹션 헤더 오른쪽으로 옮겼다. 순서 저장 중에는 추가 드래그를 잠시 막아 서버 순서와 화면 순서가 어긋나지 않게 한다.
|
||||
|
||||
## 2026-05-15 v1.1.7
|
||||
|
||||
### 사이트 로고 파일명을 교체마다 고유하게 저장
|
||||
|
||||
사이트 로고 업로드는 미디어 라이브러리에 `시스템` 폴더 메타로 남지만, 기존 구현은 항상 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 덮어썼다. 운영 브라우저와 파비콘 캐시는 같은 URL의 이미지를 오래 보관할 수 있어 파일이 바뀌어도 이전 이미지처럼 보일 수 있다. 따라서 로고와 파비콘은 업로드마다 고유 파일명으로 저장하고 사이트 설정 URL 자체를 갱신한다. 현재 사이트 설정에서 참조 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시해 실수로 이름을 바꾸거나 삭제하지 못하게 했다.
|
||||
|
||||
## 2026-05-15 v1.1.6
|
||||
|
||||
### 일반 태그도 검색 없이 보이는 관리 화면
|
||||
|
||||
메인 태그는 공개 카테고리 노출용이고 일반 태그는 게시물 분류 보조용이므로, 새 태그를 무조건 메인 태그로 올리면 공개 노출 의도가 섞인다. 다만 일반 태그를 검색해야만 볼 수 있으면 방금 만든 태그가 저장되지 않은 것처럼 느껴진다. 따라서 새 태그는 일반 태그 기본값을 유지하되, 관리자 태그 화면에서 일반 태그 전체 목록을 배지 형태로 항상 보여주고 필요할 때 메인 태그로 전환하도록 정리했다. 배지는 최근 사용순을 기본으로 하되 운영 판단에 따라 많이 사용순·이름순으로 바꿀 수 있게 했다.
|
||||
|
||||
## 2026-05-15 v1.1.5
|
||||
|
||||
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공
|
||||
|
||||
Nuxt 운영 빌드는 `public/`을 빌드 시점에 `.output/public`으로 복사해 정적 파일로 제공한다. 반면 Docker 운영 업로드는 컨테이너 실행 중 `/app/public/uploads` 볼륨에 기록되므로, 새 파일이 `.output/public` 스냅샷에 없으면 업로드 직후 이미지가 깨져 보일 수 있다. 업로드 파일은 사용자 콘텐츠이자 런타임 데이터이므로 빌드 산출물에 의존하지 않고 `/uploads/**` 요청을 `public/uploads`에서 직접 스트리밍하도록 결정했다.
|
||||
|
||||
## 2026-05-15 v1.1.4
|
||||
|
||||
### 관리자 멤버 썸네일 업로드 경로 분리
|
||||
|
||||
회원 프로필 썸네일은 관리자 계정인지 일반 회원인지와 무관하게 회원 자산이므로 `/uploads/members/avatars`에 저장해야 한다. 관리자 멤버 편집 화면이 공용 게시물 이미지 업로드 API를 사용하면 `/uploads/posts`에 저장되어 미디어 분류와 썸네일 생명주기 규칙이 어긋난다. 회원 설정 업로드와 관리자 멤버 업로드가 같은 검증·WebP 변환·1:1 크롭 로직을 쓰도록 공통 유틸로 분리하고, 관리자 멤버 화면은 회원 전용 업로드 API를 사용하도록 정리했다.
|
||||
|
||||
## 2026-05-13 v1.1.3
|
||||
|
||||
### 사이드바 행 호버 배경 분리
|
||||
|
||||
전역 `site-panel-hover`는 패널과 텍스트 색을 `color-mix`해 라이트에서도 호버가 진하게 느껴진다. 카드·태그 목록 등 다른 패널은 기존 대비를 유지하고, 왼쪽 사이드바 네비·카테고리·테마 점만 `site-sidebar-nav-row`로 분리해 라이트에서 `#F7F4EF`로 완화했다. 다크에서는 가독성을 위해 기존과 동일한 `color-mix` 호버를 유지한다.
|
||||
|
||||
## 2026-05-14 v1.1.2
|
||||
|
||||
### 태그 없을 때 “POST” 더미 표시 제거
|
||||
|
||||
태그 배열이 비어 있을 때 UI 폴백으로 `POST` 문자열을 넣어 두어, 사용자는 실제 태그가 붙은 것으로 오해했다. 저장 데이터와 무관한 표시이므로 슬러그가 있을 때만 첫 태그를 노출하고 없으면 태그 영역을 렌더하지 않는다.
|
||||
|
||||
## 2026-05-14 v1.1.1
|
||||
|
||||
### 공개 문단 행간 기본값으로 복귀
|
||||
|
||||
문단 글자 크기만 16px(`text-base`)로 고정하고 행간은 `leading-7` 대신 Tailwind·브라우저 기본(`leading-normal` 계열)에 맡긴다.
|
||||
|
||||
## 2026-05-14 v1.1.0
|
||||
|
||||
### 관리자 제목·공개 본문 타이포 마이너 조정
|
||||
|
||||
관리자 글 제목 입력은 전체 화면 폼에서 과도하게 커 보일 수 있어 `text-3xl`로 낮췄다. 공개 본문 문단은 15px·좁은 행간 조합보다 `text-base`·`leading-7`로 읽기 리듬을 맞추고, 제목 블록의 고정 `mt-12`를 제거해 본문 첫 블록과의 간격을 렌더 트리에 맡긴다.
|
||||
|
||||
## 2026-05-14 v1.0.19
|
||||
|
||||
### 수정 모드에서 보이는 hard break 표식
|
||||
|
||||
마크다운 표준의 공백 2개 hard break는 렌더링 결과는 맞지만 textarea 수정 모드에서는 공백이 보이지 않아 일반 Enter와 Shift+Enter를 구분할 수 없다. Markdown-first 에디터가 아직 plaintext textarea 기반인 동안에는 작성자가 줄바꿈 의미를 눈으로 확인할 수 있어야 하므로, Shift+Enter는 줄끝 백슬래시 hard break를 삽입하도록 바꾼다. 렌더러는 새 백슬래시 방식과 이전 공백 2개 방식을 모두 지원해 기존 저장 콘텐츠와 호환한다.
|
||||
|
||||
## 2026-05-14 v1.0.18
|
||||
|
||||
### 빈 줄 공백 보존과 미리보기 집중 모드
|
||||
|
||||
일반 Enter를 단일 줄 이동으로 유지하더라도, 작성자가 의도적으로 여러 줄을 비우면 미리보기와 공개 본문에서도 그만큼의 공백이 보여야 한다. 빈 줄을 문단 경계로만 소비하면 제목 아래나 문단 사이에 넣은 여백이 모두 사라지므로, 내용 없는 줄은 다시 spacer 블록으로 렌더링해 줄 수 정보를 보존한다. 다만 기본 문단 간격은 10px로 유지해 일반 문단 흐름과 의도적 공백을 분리한다.
|
||||
|
||||
미리보기 모드는 작성 도구가 아니라 결과 확인 화면이므로 툴바와 카드형 패널 외곽을 숨긴다. 작성 모드 줄 번호 거터는 본문 바깥 위치 안내로만 쓰고 스크롤바는 숨겨 편집 화면의 시각 잡음을 줄인다.
|
||||
|
||||
## 2026-05-14 v1.0.17
|
||||
|
||||
### textarea 기준 문단 입력 재조정
|
||||
|
||||
일반 Enter를 `\\n\\n`으로 저장하면 마크다운 문단 구분은 명확하지만, 작성 화면에서는 커서가 두 줄 내려가 보여 긴 글 작성 리듬이 어색해진다. textarea 기반 편집에서는 Affine처럼 일반 Enter를 단일 줄 이동의 새 문단으로 보고, Shift+Enter만 같은 문단 안 hard break로 구분하는 쪽이 더 편하다. 따라서 일반 Enter는 브라우저 기본 입력을 유지하고, Shift+Enter만 마크다운 hard break(`공백 2개 + \\n`)를 삽입한다. 렌더러는 hard break가 있는 행만 같은 문단으로 묶고, 일반 줄은 각각 10px 간격의 문단으로 렌더링한다.
|
||||
|
||||
### 작성 영역 카드감 축소
|
||||
|
||||
본문 textarea에 외곽 보더와 배경 카드가 있으면 관리자 화면의 다른 패널과 겹쳐 편집 영역이 과하게 박스처럼 보인다. 작성 영역의 보더와 배경을 제거하고, 줄 번호 거터는 본문 흐름 바깥 absolute 영역으로 분리한다. 현재 줄 강조 액센트는 제거하고 줄 번호 자체만 위치 안내로 사용한다.
|
||||
|
||||
## 2026-05-14 v1.0.16
|
||||
|
||||
### 문단과 줄바꿈 의미 분리
|
||||
|
||||
Markdown-first 에디터에서 빈 줄을 그대로 spacer로 보존하면 세로 간격은 조절할 수 있지만, 글쓰기 경험이 옵시디언·고스트처럼 “문단”과 “줄바꿈”으로 나뉘지 않는다. 운영 글쓰기에서는 일반 Enter를 새 문단, Shift+Enter를 같은 문단 안 줄바꿈으로 보는 Ghost형 규칙이 더 예측 가능하다. 따라서 에디터는 일반 Enter 입력 시 `\\n\\n`을 넣고, Shift+Enter는 브라우저 기본 단일 줄바꿈을 유지한다. 렌더러는 빈 줄을 별도 spacer가 아니라 문단 경계로만 쓰며, 연속 텍스트 줄은 한 문단으로 묶어 단일 줄바꿈을 `<br>`로 표시한다. 문단 간 구분감은 개별 spacer가 아니라 문단 하단 24px 간격으로 통일한다.
|
||||
|
||||
### 미리보기 전환 후 커서 복원
|
||||
|
||||
`Cmd/Ctrl+E`로 미리보기를 확인한 뒤 작성 모드로 돌아왔을 때 포커스가 사라지면 긴 글을 이어 쓰는 흐름이 끊긴다. 작성 textarea의 선택 시작·끝 위치와 스크롤 위치를 기억하고, 미리보기에서 돌아올 때 같은 위치로 포커스와 선택 영역을 복원하도록 했다.
|
||||
|
||||
## 2026-05-14 v1.0.15
|
||||
|
||||
### 마크다운 빈 줄 간격 보존
|
||||
|
||||
Markdown-first 에디터에서는 작성자가 빈 줄을 넣어 문단 사이 호흡을 직접 조절할 수 있어야 한다. 기존 `ContentMarkdownRenderer`는 빈 줄을 파싱 단계에서 건너뛰어 1줄과 2줄의 차이가 모두 사라졌다. 빈 줄과 레거시 빈 문단 마커를 `spacer` 블록으로 렌더링해 공개 본문과 관리자 미리보기에서 작성자가 넣은 세로 간격을 보존한다. 세부 높이는 이후 본문 스타일 QA에서 조정하되, 우선 줄 수 정보가 사라지지 않는 것을 기준으로 한다.
|
||||
|
||||
## 2026-05-14 v1.0.14
|
||||
|
||||
### Markdown-first 전환 후 레거시 본문 정규화
|
||||
|
||||
Markdown-first 에디터로 전환한 뒤에도 브라우저 자동 저장본이나 이전 블록 에디터 상태가 배열·객체 형태로 남아 있으면, 저장 API의 `content: string` 검증에서 “게시물 입력 형식” 오류가 날 수 있다. 데이터베이스 저장 형식은 마크다운 문자열로 유지하되, 클라이언트 복원 단계와 서버 입력 검증 단계에 공통 정규화 유틸을 두어 레거시 블록 값을 마크다운으로 변환한다. 이렇게 하면 사용자가 기존 자동 저장본을 복원해도 발행 흐름이 끊기지 않고, 이후 페이지 편집 쪽도 같은 기준을 공유한다.
|
||||
|
||||
## 2026-05-14 v1.0.13
|
||||
|
||||
### Markdown 에디터 붙여넣기와 미디어 편집 보강
|
||||
|
||||
textarea 기반 Markdown-first 편집은 범위 선택과 복사/붙여넣기 문제를 줄였지만, 외부 웹 글을 붙여넣을 때 HTML 구조가 일반 텍스트로 무너지거나 이미지·갤러리 마크다운을 직접 고쳐야 하는 불편이 남았다. 우선 브라우저 클립보드의 `text/html`을 제목·문단·목록·링크·강조·이미지 중심의 마크다운 조각으로 변환하고, 커서가 이미지 또는 갤러리 블록 안에 있을 때 별도 편집 패널을 보여 alt·URL·너비·갤러리 순서를 수정하도록 했다. 작성과 미리보기 전환은 반복 작업이므로 `Cmd/Ctrl+E` 단축키로 접근하게 하고, 관리자 미리보기는 공개 렌더러를 쓰되 밝은 관리자 패널에 맞는 색상 변수를 별도로 고정한다. 완전한 옵시디언식 토큰 숨김 Live Preview와 표준 마크다운 파서는 더 큰 편집 엔진 선택이 필요하므로 후속 단계로 둔다.
|
||||
|
||||
## 2026-05-13 v1.0.12
|
||||
|
||||
### Markdown 에디터 줄 번호 거터
|
||||
|
||||
옵시디언·CodeMirror는 편집 줄 왼쪽에 줄 번호와 현재 줄 하이라이트를 둔다. 본문 편집이 textarea 단일 호스트로 바뀐 뒤에도 동일한 방향의 안내가 있으면 긴 본문에서 위치 파악이 쉬워진다. CodeMirror 수준의 **시각 줄**(줄바꿈 wrap) 단위 번호는 별도 미러 레이아웃 없이는 맞추기 어렵고, 우선 `\\n` 기준 **논리 줄** 번호와 캐럿이 속한 논리 줄의 거터 셀 배경 강조, 스크롤 동기화만 구현했다.
|
||||
|
||||
## 2026-05-14 v1.0.11
|
||||
|
||||
### 관리자 글쓰기를 Markdown-first로 전환
|
||||
|
||||
블록형 에디터는 이미지·갤러리 같은 카드형 입력에는 편하지만, 여러 문단 범위 선택, 외부 블로그/옵시디언 복사·붙여넣기, 다중 블록 복사 같은 기본 글쓰기 동작이 브라우저 텍스트 편집 모델과 계속 충돌했다. 저장 포맷은 이미 마크다운 문자열이므로 본문 편집의 원본도 마크다운 문자열로 되돌리고, textarea 기반 작성 모드와 공개 렌더러 기반 미리보기를 제공한다. 이미지와 갤러리는 기존 업로드·미디어 라이브러리 API를 유지하고 커서 위치에 마크다운으로 삽입한다. 옵시디언식 토큰 숨김 Live Preview는 이 기반이 안정화된 뒤 별도 단계로 확장한다.
|
||||
|
||||
## 2026-05-13 v1.0.10
|
||||
|
||||
### 관리자 블록 에디터를 v1.0.5 파일 기준으로 복원
|
||||
|
||||
v1.0.6부터 적용했던 다중 줄 붙여넣기 분할, Cmd/Ctrl+A로 전체 마크다운 복사, 블록 단위 범위 선택·레인 드래그·복사 가로채기 등이 실제 사용에서 어색하다는 피드백이 있어, `AdminBlockEditor.vue`는 Git 태그 `v1.0.5` 시점 내용으로 되돌렸다. Docker·부트스트랩 등 v1.0.5 이후 서버/배포 변경은 유지하고 에디터 파일만 이전 동작으로 맞춘다.
|
||||
|
||||
## 2026-05-14 v1.0.5
|
||||
|
||||
### Docker 런타임 환경 변수 우선
|
||||
|
||||
Nuxt `runtimeConfig`에 `process.env.*`를 직접 대입하면 Docker 이미지 빌드 시점에 값이 비어 있는 상태로 번들에 들어갈 수 있다. 운영 컨테이너는 `.env.production`을 런타임에 주입하므로, 서버 전용 비밀값과 DB 연결값은 `useRuntimeConfig()`보다 `process.env`를 우선 조회하도록 공통 유틸을 추가했다. 이 기준은 관리자 최초 로그인, 세션 서명, DB 연결, 이메일 OTP 설정에 적용한다.
|
||||
|
||||
## 2026-05-14 v1.0.4
|
||||
|
||||
### 최초 관리자 기준을 owner/admin 존재 여부로 변경
|
||||
|
||||
운영 DB에 일반 회원이 먼저 생성되면 기존의 “사용자 0명” 기준 부트스트랩이 더 이상 동작하지 않아 관리자 계정이 없는 잠금 상태가 생길 수 있다. 최초 관리자 필요 여부를 전체 사용자 수가 아니라 `owner`/`admin` 권한 보유자 존재 여부로 판단하고, `ADMIN_EMAIL`과 같은 일반 회원이 이미 있으면 해당 회원을 `owner`로 승격하면서 `ADMIN_PASSWORD` 기준 비밀번호 해시를 갱신해 운영 복구 경로를 보장한다. 이미 `owner`/`admin`이 있으면 환경 변수 로그인은 우회 권한으로 쓰지 않는다.
|
||||
|
||||
## 2026-05-14 v1.0.3
|
||||
|
||||
### NAS에서 Postgres init 디렉터리 Permission denied
|
||||
|
||||
Docker가 호스트의 `db/migrations`를 `postgres:16-alpine`의 `/docker-entrypoint-initdb.d`에 마운트할 때, NAS 파일 시스템이나 복사 시 기본 umask 때문에 디렉터리가 `700`·파일이 `600`만 되면 컨테이너 내부 `postgres` UID로는 목록을 읽지 못한다. 엔트리포인트가 `ls /docker-entrypoint-initdb.d`에서 실패하면 DB가 즉시 종료되고 `restart: unless-stopped`로 루프에 들어간다. 배포 문서에 `chmod -R a+rX db/migrations` 및 상위 경로 `a+x` 절차를 명시하고, compose에 주석으로 동일 원인을 남겨 재발 시 빠르게 대응할 수 있게 했다.
|
||||
|
||||
## 2026-05-14 v1.0.1
|
||||
|
||||
### Docker Compose 전용 네트워크 대역 명시
|
||||
|
||||
NAS에서 이미 실행 중인 Docker 서비스가 많으면 Docker가 자동으로 고르는 기본 브리지 네트워크 주소 풀이 기존 네트워크와 겹쳐 Compose 실행이 실패할 수 있다. 기존 네트워크를 정리할 수 없는 운영 환경을 고려해 이 프로젝트 전용 브리지 네트워크와 기본 subnet을 명시하고, 필요 시 `.env.production`의 `DOCKER_SUBNET`으로 다른 사설 대역을 지정할 수 있게 했다.
|
||||
|
||||
## 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
|
||||
|
||||
### 초기 제품 방향 결정
|
||||
|
||||
246
docs/map.md
246
docs/map.md
@@ -6,58 +6,266 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| layouts/default.vue | 메인, 목록, 태그 페이지 |
|
||||
| layouts/post.vue | 개별 게시물 |
|
||||
| layouts/admin.vue | 관리자 전체 |
|
||||
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
|
||||
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
||||
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
||||
| 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` |
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
|
||||
## 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`) 재확인 |
|
||||
| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) |
|
||||
|
||||
## 사이트 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단 |
|
||||
| components/site/LeftSidebar.vue | 메인 화면 왼쪽 |
|
||||
| components/site/RightSidebar.vue | 메인 화면 오른쪽 |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드 |
|
||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
|
||||
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
|
||||
## 관리자 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 제목 입력 text-3xl, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 백슬래시 hard break 줄바꿈 입력, 작성 모드 왼쪽 바깥 absolute 논리 줄 번호 거터·거터 스크롤 동기화·스크롤바 숨김, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 작성 모드 툴바 마크다운 삽입, 미리보기 모드 툴바 숨김, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
|
||||
| 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/ProseHeading.vue | h1~h6 제목 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||
| components/content/ProseList.vue | 목록 |
|
||||
| components/content/ProseBlockquote.vue | 인용구 |
|
||||
| components/content/ProseButton.vue | 버튼 |
|
||||
| components/content/ProseCallout.vue | Callout 카드 |
|
||||
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 배경 프리셋, 상단 여백 중심) |
|
||||
| components/content/ProseToggle.vue | Toggle 카드 |
|
||||
| components/content/ProseVideo.vue | 비디오 |
|
||||
| components/content/ProseAudio.vue | 오디오 |
|
||||
| components/content/ProseFile.vue | 파일 |
|
||||
| components/content/ProseProduct.vue | 상품 카드 |
|
||||
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) |
|
||||
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
|
||||
| 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/posts/index.vue | 글 목록 |
|
||||
| pages/admin/posts/new.vue | 글 작성 |
|
||||
| pages/admin/posts/[id].vue | 글 수정 |
|
||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||
| pages/admin/posts/index.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/tags/index.vue | 태그 관리 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 |
|
||||
| pages/admin/pages/new.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 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
|
||||
## 공개 페이지
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈 |
|
||||
| pages/posts/[slug].vue | 블로그 글 상세 |
|
||||
| pages/tags/[slug].vue | 태그별 글 목록 |
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.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/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(원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
|
||||
| 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/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-YYYYMM-*.webp`, `/uploads/system/favicon-YYYYMM-*.png` 생성, `시스템` 미디어 메타 저장) |
|
||||
| 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]/avatar.post.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/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·WebP 변환·중앙 1:1 크롭·저장 유틸 |
|
||||
| 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 컨테이너 실행 초안 |
|
||||
|
||||
543
docs/spec.md
543
docs/spec.md
@@ -6,8 +6,9 @@
|
||||
- **유형**: 커스텀 블로그/CMS
|
||||
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
||||
- **참조**: 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
|
||||
- **스타일**: Tailwind 엔트리는 `assets/css/main.css` 한 곳(`nuxt.config`의 `tailwindcss.cssPath`)이며, `tailwind.config.js`의 `content`가 Vue·composables·modules·plugins를 스캔한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,15 +18,61 @@
|
||||
|
||||
| 요소 | 크기/속성 |
|
||||
|------|-----------|
|
||||
| Header | 높이 57px |
|
||||
| Left Aside | 너비 287px, 패딩 12px 12px 12px 0 |
|
||||
| Main | 너비 720px, 패딩 32px 24px (헤더), 16px 24px (섹션) |
|
||||
| Right Aside | 너비 287px, 패딩 20px 0 20px 20px |
|
||||
| Header | 높이 57px, `sticky top-0`, `shrink-0`. 내부는 `grid-cols-3`로 **좌(브랜드·메뉴) / 중앙(검색, `md+`에서만 표시) / 우(사용자 메뉴)** 배치해 검색 패널을 가운데 열에 정렬한다. 검색 버튼은 중앙 열 안에서 `max-w-[min(470px,100%)]`로 폭을 제한한다. |
|
||||
| Shell | `min-height: 100vh`, `flex` 세로 컬럼 |
|
||||
| 그리드(데스크톱 `lg+`) | `items-start`, 3열 그리드(`287px / minmax(0,1fr) / 287px`)를 사용하고 열 간 `column-gap`은 두지 않는다(`gap-x-0`). 경계선은 사이드바 보더로만 구분해 이중 패딩처럼 보이는 여백을 방지한다. 긴 본문은 **문서(`html`/`body`) 스크롤**로 처리한다. |
|
||||
| 그리드(모바일 `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 페이지
|
||||
|
||||
- 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 페이지
|
||||
|
||||
@@ -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 # 관리자 화면
|
||||
```
|
||||
|
||||
### 관리자 레이아웃
|
||||
|
||||
- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
|
||||
- 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
|
||||
- 관리자 우측 캔버스는 기본 `min-h-screen`과 `bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
|
||||
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
|
||||
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||
- 로그아웃은 사이드바 상단 메뉴가 아니라 하단 사용자 썸네일 드롭다운 안에서 제공한다. 하단에는 사용자 썸네일 트리거와 설정 아이콘을 둔다.
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 구조
|
||||
@@ -52,6 +133,7 @@ layouts/
|
||||
```
|
||||
components/site/
|
||||
├── SiteHeader.vue # 상단 헤더
|
||||
├── SiteSearchModal.vue # 통합 검색 모달(`/`·헤더 검색 영역, Tags·Posts 결과)
|
||||
├── LeftSidebar.vue # 왼쪽 사이드바
|
||||
├── RightSidebar.vue # 오른쪽 사이드바
|
||||
├── MainColumn.vue # 메인 컬럼
|
||||
@@ -75,19 +157,56 @@ components/content/
|
||||
├── ProseAudio.vue # Audio 카드
|
||||
├── ProseFile.vue # File 카드
|
||||
├── ProseProduct.vue # Product 카드
|
||||
├── ProseBookmark.vue # 북마크 카드(썸네일·제목·도메인)
|
||||
├── ProseSignup.vue # 회원가입/뉴스레터 CTA 카드
|
||||
├── 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` (그리드 + 라이트박스)
|
||||
- 문단과 줄바꿈
|
||||
- 관리자 Markdown-first 에디터에서 일반 Enter는 브라우저 기본 단일 줄 이동으로 새 문단을 만든다.
|
||||
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
|
||||
- 공개 본문 렌더러는 줄끝 백슬래시 또는 공백 2개 hard break가 있는 행만 같은 문단으로 묶고 `<br>`로 표시한다.
|
||||
- 내용 없는 빈 줄과 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)는 spacer 블록으로 렌더링해 작성자가 비운 줄 수만큼 공백을 보존한다.
|
||||
- 문단 하단 기본 간격은 10px(`mb-2.5`) 기준이며, 문단 글자 크기는 `ContentMarkdownRenderer` 문단에 `text-base`(16px·`1rem`)만 지정하고 행간은 Tailwind·브라우저 기본에 맡긴다.
|
||||
- 제목은 `ProseHeading`에서 단계별 크기·굵기를 적용하고, 첫 제목(`first:`)을 제외한 상단 추가 여백은 컴포넌트 스타일로만 조정한다.
|
||||
- 카드류
|
||||
- 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를 사용
|
||||
- 로컬 개발 서버는 개발 DB만 연결
|
||||
- NAS 배포 환경은 운영 DB만 연결
|
||||
- 운영 환경(`NODE_ENV=production`)에서는 `DATABASE_URL` 누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패
|
||||
- Docker Compose는 전용 브리지 네트워크를 사용하며 기본 subnet은 `DOCKER_SUBNET`(`10.250.50.0/24`)으로 관리
|
||||
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
||||
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
||||
|
||||
@@ -100,12 +219,54 @@ components/content/
|
||||
| slug | String | URL 슬러그 |
|
||||
| content | Text | 마크다운 콘텐츠 |
|
||||
| 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 |
|
||||
| published_at | DateTime | 발행일 |
|
||||
| created_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 (고정 페이지)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -114,7 +275,7 @@ components/content/
|
||||
| title | String | 제목 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| content | Text | 마크다운 콘텐츠 |
|
||||
| featured_image | String | 대표 이미지 |
|
||||
| featured_image | String nullable | 대표 이미지 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -126,6 +287,55 @@ components/content/
|
||||
| name | String | 태그명 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| 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 (다대다)
|
||||
|
||||
@@ -133,32 +343,264 @@ components/content/
|
||||
|------|------|------|
|
||||
| post_id | UUID | FK → Posts |
|
||||
| tag_id | UUID | FK → Tags |
|
||||
| created_at | DateTime | 생성일 |
|
||||
|
||||
---
|
||||
|
||||
## API 구조
|
||||
|
||||
> 아직 구현 전 설계안이다. 실제 구현 시 응답 구조와 엔드포인트가 바뀌면 이 문서를 먼저 갱신한다.
|
||||
> 현재 API는 Nuxt `server/api` 내부에 샘플 데이터 기반으로 구현되어 있다. DB 연결 후 같은 응답 구조를 유지하되 저장소만 교체한다.
|
||||
|
||||
### 백엔드 구성
|
||||
|
||||
- 별도 `backend/` 앱을 두지 않고 Nuxt/Nitro 서버 기능을 사용
|
||||
- 공개 API는 `server/api`에 작성
|
||||
- 서버 공통 스키마와 샘플 데이터는 `server/utils`에 작성
|
||||
- PostgreSQL 연결과 조회 로직은 `server/repositories`에 작성
|
||||
- `DATABASE_URL`이 없으면 샘플 데이터 저장소를 사용
|
||||
- 초기 단계에서는 같은 앱 배포로 관리 비용을 낮춤
|
||||
- 독립 API 서버가 필요해지는 시점에만 백엔드 분리를 재검토
|
||||
|
||||
### 공개 API (`/api/`)
|
||||
|
||||
- `GET /api/posts` - 게시물 목록
|
||||
- `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/:slug` - 고정 페이지 상세
|
||||
- `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)가 포함될 수 있다.
|
||||
|
||||
### 업로드 파일 제공
|
||||
|
||||
- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다.
|
||||
|
||||
### 관리자 API (`/admin/api/`)
|
||||
|
||||
- `POST /admin/api/auth/login` - 로그인
|
||||
- `POST /admin/api/auth/logout` - 로그아웃
|
||||
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
|
||||
- `GET /admin/api/posts` - 글 목록
|
||||
- `POST /admin/api/posts` - 글 작성
|
||||
- `GET /admin/api/posts/:id` - 글 상세
|
||||
- `PUT /admin/api/posts/:id` - 글 수정
|
||||
- `DELETE /admin/api/posts/:id` - 글 삭제
|
||||
- `POST /admin/api/posts/:id/publish` - 글 발행
|
||||
- `GET /admin/api/tags` - 태그 목록
|
||||
- `GET /admin/api/pages` - 고정 페이지 목록
|
||||
- `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` 등 넘버링, `/uploads/posts/YYYY/MM` 저장)
|
||||
- `POST /admin/api/member-avatar` - 관리자 새 회원 생성 전 썸네일 사전 업로드(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
- `PUT /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`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다.
|
||||
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
|
||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
|
||||
> 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
|
||||
> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
|
||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
|
||||
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
### 관리자 글 편집
|
||||
|
||||
- 글 작성/수정 화면은 Markdown-first 에디터(`AdminMarkdownEditor`)를 사용한다.
|
||||
- 작성 모드 textarea 왼쪽 바깥에 **논리 줄 번호** 거터(`\\n` 기준 줄 수, 빈 본문은 1줄)를 absolute 영역으로 두고 textarea와 거터의 세로 스크롤을 동기화한다. 거터 스크롤바는 숨긴다. 한 논리 줄이 화면에서 여러 줄로 줄바꿈될 때는 옵시디언·CodeMirror처럼 시각 줄마다 번호가 늘지 않으며, 논리 줄 단위로만 맞춘다.
|
||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
|
||||
- 관리자 게시물/페이지 저장 API는 레거시 블록 배열·객체 본문 값이 들어와도 마크다운 문자열로 정규화한 뒤 저장한다.
|
||||
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
|
||||
- 본문 작성 모드에서 일반 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 처리한다. 일반 Enter는 단일 줄 이동으로 보여야 하며, Shift+Enter는 수정 모드에서도 보이는 줄끝 백슬래시 hard break를 남긴다.
|
||||
- 클립보드에 `text/html`이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다.
|
||||
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용하며, 툴바와 카드형 패널 외곽을 숨겨 본문만 표시한다.
|
||||
- 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
|
||||
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I`는 현재 선택 텍스트에 각각 굵게, 기울임 마크다운을 적용한다.
|
||||
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
|
||||
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
|
||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `{width=...}` 형식으로 삽입한다.
|
||||
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
|
||||
- 이미지 너비 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 작성 모드에서 커서가 이미지 마크다운 줄 또는 `:::gallery` 블록 안에 있으면 현재 미디어 블록 편집 패널을 표시한다.
|
||||
- 현재 미디어 블록 편집 패널은 alt, URL, 너비 값을 수정하고 갤러리 이미지 순서 변경, 삭제, 미디어 라이브러리 이미지 추가를 지원한다.
|
||||
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 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 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 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-YYYYMM-random.webp`와 `/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 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-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
|
||||
### 관리자 인증
|
||||
|
||||
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||
- DB에 owner/admin 계정이 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
|
||||
- 최초 owner 생성 시 `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`만 사용하며, 값이 없으면 서버 오류로 실패한다.
|
||||
- Docker 운영 서버 환경 변수는 이미지 빌드 시점 `runtimeConfig`보다 컨테이너 런타임 `process.env` 값을 우선한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,10 +611,35 @@ components/content/
|
||||
```
|
||||
/uploads/posts/YYYY/MM/filename.webp
|
||||
/uploads/pages/YYYY/MM/filename.webp
|
||||
/uploads/system/logo.png
|
||||
/uploads/system/favicon.png
|
||||
/uploads/members/avatars/YYYY/MM/filename.webp
|
||||
/uploads/system/logo-YYYYMM-random.webp
|
||||
/uploads/system/favicon-YYYYMM-random.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`에 수동 생성·삭제할 수 없다.
|
||||
- 사이트 로고와 파비콘은 `public/uploads/system/`에 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`시스템`**으로 저장한다. 현재 사이트 설정의 `logo_url` 또는 `favicon_url`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다.
|
||||
- 레거시 메타 값 `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을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수 (.env)
|
||||
@@ -181,34 +648,62 @@ components/content/
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=
|
||||
DATABASE_NAME=
|
||||
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_PASSWORD=
|
||||
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
|
||||
SITE_URL=https://sori.studio
|
||||
SITE_TITLE=sori.studio
|
||||
NUXT_PUBLIC_SITE_URL=https://sori.studio
|
||||
NUXT_PUBLIC_SITE_TITLE=sori.studio
|
||||
|
||||
# Server
|
||||
APP_PORT=43118
|
||||
```
|
||||
|
||||
### 환경 파일 기준
|
||||
|
||||
| 파일 | 용도 | DB |
|
||||
|------|------|----|
|
||||
| `.env.development` | 로컬 개발 | 개발 DB |
|
||||
| `.env.production` | NAS 운영 | 운영 DB |
|
||||
| `.env.example` | 공유 예시 | 실제 접속 정보 없음 |
|
||||
| `.env.development` | 로컬 개발, Git 제외 | 개발 DB |
|
||||
| `.env.production` | NAS 운영, Git 제외 | 운영 DB |
|
||||
| `.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
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
73
docs/todo.md
73
docs/todo.md
@@ -2,68 +2,41 @@
|
||||
|
||||
## 1차 관리자 개발
|
||||
|
||||
- [ ] 로그인 기능 구현
|
||||
- [ ] 글 목록 조회
|
||||
- [ ] 글 작성/수정 (마크다운 에디터)
|
||||
- [ ] 글 발행/비공개 전환
|
||||
- [ ] 태그 관리 (생성/수정/삭제)
|
||||
- [ ] 이미지 업로드
|
||||
- [ ] Markdown-first 에디터 3차 개선: 옵시디언식 Live Preview(마크다운 토큰 숨김/백스페이스 복원), 표준 마크다운 파서 도입 검토, HTML 붙여넣기 변환 범위 확대
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- [ ] 페이지 관리 (CRUD)
|
||||
- [ ] 사이트 설정
|
||||
- [ ] 메뉴/네비게이션 관리
|
||||
- [ ] 미디어 라이브러리
|
||||
|
||||
## 3차 관리자 개발
|
||||
|
||||
- [ ] 예약 발행
|
||||
- [ ] 초안 자동 저장
|
||||
- [ ] SEO 설정
|
||||
- [ ] OG 이미지 설정
|
||||
- [ ] 글 미리보기
|
||||
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
- [ ] SiteHeader.vue 구현 (57px 높이)
|
||||
- [ ] LeftSidebar.vue 구현 (287px, 패딩 12px 12px 12px 0)
|
||||
- [ ] RightSidebar.vue 구현 (287px, 패딩 20px 0 20px 20px)
|
||||
- [ ] MainColumn.vue 구현 (720px)
|
||||
- [ ] PostCard.vue 구현
|
||||
- [ ] TagHeader.vue 구현
|
||||
- [ ] Thred 참고 화면 기준 시각 QA
|
||||
- [ ] 사이드바 토글 애니메이션 세부 조정
|
||||
- [ ] 사용자 화면 테마 전환 초기 로드 깜빡임(FART) 최소화
|
||||
- [ ] 회원 설정 썸네일 수동 크롭 UI(선택 영역 지정) 도입 검토
|
||||
|
||||
## 콘텐츠 스타일 구현
|
||||
|
||||
- [ ] ProseHeading (h1~h6)
|
||||
- [ ] ProseList (Ordered/Unordered)
|
||||
- [ ] ProseBlockquote
|
||||
- [ ] ProseImage (Regular/Wide/Full-width)
|
||||
- [ ] ProseButton (Left-aligned/Centered)
|
||||
- [ ] ProseCallout
|
||||
- [ ] ProseToggle
|
||||
- [ ] ProseVideo
|
||||
- [ ] ProseAudio
|
||||
- [ ] ProseFile
|
||||
- [ ] ProseProduct
|
||||
- [ ] ProseHeaderCard (Simple/Wide/Full-width/Split)
|
||||
- [ ] ProseEmbed (YouTube, Twitter)
|
||||
- [ ] ProseHeading 실제 스타일 세부 조정
|
||||
- [ ] ProseList 실제 스타일 세부 조정
|
||||
- [ ] ProseBlockquote 실제 스타일 세부 조정
|
||||
- [ ] ProseImage Regular/Wide/Full-width 동작 검증
|
||||
- [ ] ProseImage Wide/Full-width 화면 이탈과 스킨별 최대 폭 기준 재정리
|
||||
- [ ] ProseButton Left/Center 정렬 검증
|
||||
- [ ] ProseCallout 실제 스타일 세부 조정
|
||||
- [ ] ProseToggle 실제 스타일 세부 조정
|
||||
- [ ] ProseVideo 실제 임베드 렌더링 연결
|
||||
- [ ] ProseAudio 실제 오디오 렌더링 연결
|
||||
- [ ] ProseFile 실제 파일 데이터 연결
|
||||
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
- [ ] Posts 테이블 설계
|
||||
- [ ] Pages 테이블 설계
|
||||
- [ ] Tags 테이블 설계
|
||||
- [ ] PostTags 테이블 설계
|
||||
- [ ] 로컬 개발 DB 연결 설정 작성
|
||||
- [ ] NAS 운영 DB 연결 설정 작성
|
||||
- [ ] 개발 DB와 운영 DB 분리 검증 절차 작성
|
||||
- [ ] CloudBeaver 등 DB 관리 도구 연결 방식 결정
|
||||
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
|
||||
- [ ] 이전에 원격에 올라간 관리자 비밀번호가 실제 사용 값이면 즉시 폐기 및 변경
|
||||
|
||||
## 배포
|
||||
|
||||
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
||||
- [ ] 로컬 개발 환경 가이드 작성
|
||||
- [ ] Dockerfile 작성
|
||||
- [ ] docker-compose.yml 작성
|
||||
- [ ] .env.example 작성
|
||||
- [ ] NAS 운영 환경 변수 최종 점검
|
||||
- [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA
|
||||
|
||||
1135
docs/update.md
1135
docs/update.md
File diff suppressed because it is too large
Load Diff
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>
|
||||
172
lib/markdown-content-normalizer.js
Normal file
172
lib/markdown-content-normalizer.js
Normal file
@@ -0,0 +1,172 @@
|
||||
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||
|
||||
const blockSpacingTypes = new Set(['list'])
|
||||
|
||||
/**
|
||||
* 이미지 블록을 마크다운 문자열로 변환한다.
|
||||
* @param {Object} image - 이미지 데이터
|
||||
* @returns {string} 이미지 마크다운
|
||||
*/
|
||||
const serializeImageBlock = (image = {}) => {
|
||||
const url = String(image.url || '').trim()
|
||||
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const width = image.width && image.width !== 'regular'
|
||||
? `{width=${image.width}}`
|
||||
: ''
|
||||
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 블록 하나를 마크다운 조각으로 변환한다.
|
||||
* @param {Object} block - 레거시 에디터 블록
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @param {number} total - 전체 블록 수
|
||||
* @returns {{ type: string, value: string }|null} 마크다운 조각
|
||||
*/
|
||||
const serializeLegacyBlock = (block = {}, index = 0, total = 1) => {
|
||||
if (typeof block.value === 'string') {
|
||||
return block.value.trim()
|
||||
? { type: block.type || 'paragraph', value: block.value }
|
||||
: null
|
||||
}
|
||||
|
||||
const type = block.type || 'paragraph'
|
||||
const rawText = String(block.text || '')
|
||||
const text = rawText.trim()
|
||||
|
||||
if (type === 'divider') {
|
||||
return { type, value: '---' }
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
const image = serializeImageBlock(block)
|
||||
return image ? { type, value: image } : null
|
||||
}
|
||||
|
||||
if (type === 'gallery') {
|
||||
const images = Array.isArray(block.images)
|
||||
? block.images.map(serializeImageBlock).filter(Boolean)
|
||||
: []
|
||||
|
||||
return images.length
|
||||
? { type, value: [':::gallery', ...images, ':::'].join('\n') }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'callout') {
|
||||
const emoji = block.calloutEmojiEnabled === false
|
||||
? 'none'
|
||||
: (block.calloutEmoji || '💡')
|
||||
const background = block.calloutBackground || 'blue'
|
||||
|
||||
return text
|
||||
? { type, value: `:::callout emoji=${emoji} bg=${background}\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'toggle') {
|
||||
const title = String(block.title || '').trim()
|
||||
|
||||
return title || text
|
||||
? { type, value: `:::toggle ${title || '더 보기'}\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'embed') {
|
||||
const url = String(block.url || '').trim()
|
||||
|
||||
return url
|
||||
? { type, value: `:::embed\n${url}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'paragraph' && !text) {
|
||||
return index === total - 1
|
||||
? null
|
||||
: { type, value: BLANK_PARAGRAPH_MARKER }
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (type === 'heading') {
|
||||
return { type, value: `${'#'.repeat(block.level || 2)} ${text}` }
|
||||
}
|
||||
|
||||
if (type === 'quote') {
|
||||
return { type, value: `> ${text}` }
|
||||
}
|
||||
|
||||
if (type === 'list') {
|
||||
return { type, value: `- ${text}` }
|
||||
}
|
||||
|
||||
if (type === 'code') {
|
||||
return { type, value: `\`\`\`\n${rawText}\n\`\`\`` }
|
||||
}
|
||||
|
||||
return { type, value: text }
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 블록 배열을 저장용 마크다운 문자열로 변환한다.
|
||||
* @param {Array<Object>} blocks - 레거시 블록 목록
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
const serializeLegacyBlocks = (blocks) => blocks
|
||||
.map((block, index) => serializeLegacyBlock(block, index, blocks.length))
|
||||
.filter(Boolean)
|
||||
.reduce((markdown, block, index, blocksList) => {
|
||||
if (index === 0) {
|
||||
return block.value
|
||||
}
|
||||
|
||||
const previousBlock = blocksList[index - 1]
|
||||
const joiner = blockSpacingTypes.has(previousBlock.type) && blockSpacingTypes.has(block.type)
|
||||
? '\n'
|
||||
: '\n\n'
|
||||
|
||||
return `${markdown}${joiner}${block.value}`
|
||||
}, '')
|
||||
|
||||
/**
|
||||
* 게시물/페이지 본문 값을 저장 가능한 마크다운 문자열로 정규화한다.
|
||||
* @param {unknown} value - 본문 값
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
export const normalizeMarkdownContent = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return serializeLegacyBlocks(value)
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
if (typeof value.content === 'string') {
|
||||
return value.content
|
||||
}
|
||||
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return serializeLegacyBlocks(value.blocks)
|
||||
}
|
||||
|
||||
if (typeof value.markdown === 'string') {
|
||||
return value.markdown
|
||||
}
|
||||
|
||||
if (typeof value.type === 'string') {
|
||||
const block = serializeLegacyBlock(value)
|
||||
return block?.value || ''
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
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.1.8",
|
||||
"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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user