Compare commits
236 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28d95129c2 | |||
| 60ca6e0930 | |||
| ccb6db5f89 | |||
| 629ef8c4c6 | |||
| cc9e5949fa | |||
| 5c93643949 | |||
| 9a4820e69c | |||
| 928b8446b4 | |||
| 4c0875446b | |||
| 16a12d304d | |||
| 6daf9ca15e | |||
| 56a2c23471 | |||
| 09b6c51048 | |||
| 264f551cb4 | |||
| 94226423c7 | |||
| 67fbba3814 | |||
| f048eaac2b | |||
| 648ce5fbab | |||
| 35b9893eab | |||
| 675e6bca78 | |||
| 83f66a4b93 | |||
| accd933e99 | |||
| 24611af8b6 | |||
| b38fc9f154 | |||
| 2cb1ff4651 | |||
| b5970c8ada | |||
| 79009b21e6 | |||
| 21d01632be | |||
| b3c7f26d10 | |||
| 4da1ade2cf | |||
| e3b8087b09 | |||
| ba17e3aa18 | |||
| 093d09c8bf | |||
| 1bcd2f6898 | |||
| 5b78a8c92f | |||
| 600b0fd1d9 | |||
| 1a670e237f | |||
| 0e2b701862 | |||
| e2df9d55ac | |||
| c2e69d9048 | |||
| 9d91355c81 | |||
| ef1a9d9032 | |||
| 5732a27498 | |||
| 212bd3f34f | |||
| 5735fd5046 | |||
| a4c1b42369 | |||
| f8621d49d8 | |||
| 7c8245c4e9 | |||
| 11203ba251 | |||
| abce690546 | |||
| 052ce316df | |||
| 71046ed883 | |||
| dc50780ff8 | |||
| edbbd3c83c | |||
| ac57ff458d | |||
| cb92b32f9c | |||
| 602106ac9d | |||
| 7f017a03a5 | |||
| 8ca63c0d00 | |||
| fd9416c0e4 | |||
| d7a3149ea1 | |||
| e78e09f3fd | |||
| a5ae2c3fce | |||
| 3843e16d9f | |||
| 6333c4254f | |||
| b989193dab | |||
| 62ceaa3591 | |||
| a25306389b | |||
| 0ad2ab3f9d | |||
| 6536465b12 | |||
| dcd1060ec7 | |||
| 38ca3a4709 | |||
| 8f53210756 | |||
| 9e5728074a | |||
| 10c5a099fc | |||
| 6919669330 | |||
| 095a8fa5f0 | |||
| f8e04003fd | |||
| a396d1d022 | |||
| cc34db40f2 | |||
| 0e70d4482d | |||
| c43873ce5f | |||
| abb77dbb4d | |||
| 3623305119 | |||
| b6a3228b09 | |||
| b77f37a94e | |||
| 02d33996c5 | |||
| 797a6dd5a0 | |||
| 3fb8a40031 | |||
| 666bd304fc | |||
| 0c051cbe3b | |||
| c9b484e4c8 | |||
| a867269d9b | |||
| c474a8b9a3 | |||
| 47620ab24c | |||
| 14ce897bf8 | |||
| 6fd61911fd | |||
| 2074b0b93a | |||
| ca1e17890b | |||
| 2768975752 | |||
| 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
|
||||||
39
.env.example
Normal file
39
.env.example
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 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
|
||||||
|
# ANALYTICS_HASH_SECRET= ← 선택. 일별 방문자 해시용 비밀. 비우면 MEMBER_SESSION_SECRET을 대신 사용.
|
||||||
|
|
||||||
|
# Upload
|
||||||
|
UPLOAD_DIR=/uploads
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
MAX_VIDEO_FILE_SIZE=209715200
|
||||||
|
MAX_AUDIO_FILE_SIZE=52428800
|
||||||
|
MAX_DOCUMENT_FILE_SIZE=52428800
|
||||||
|
POST_EXPORT_MAX_FILE_SIZE_BYTES=524288000
|
||||||
|
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/
|
.output/
|
||||||
.nuxt/
|
.nuxt/
|
||||||
dist/
|
dist/
|
||||||
|
public/uploads/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
@@ -26,4 +27,8 @@ Thumbs.db
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Reference theme sample (do not commit)
|
||||||
|
ZCF-v1.0.5/
|
||||||
|
sample 깃에 올리지말것/
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
|
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
|
||||||
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
|
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
|
||||||
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
||||||
|
- 커밋까지 완료한 작업은 사용자가 중단을 요청하지 않는 한 `git push`로 원격에 반영해 푸시 누락을 피한다.
|
||||||
|
|
||||||
민감 정보 예시:
|
민감 정보 예시:
|
||||||
- 실명
|
- 실명
|
||||||
@@ -157,6 +158,7 @@
|
|||||||
- 기존 API 호출 패턴을 따른다.
|
- 기존 API 호출 패턴을 따른다.
|
||||||
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
|
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
|
||||||
- 하드코딩된 값 사용을 금지한다.
|
- 하드코딩된 값 사용을 금지한다.
|
||||||
|
- 초기 백엔드는 별도 앱으로 분리하지 않고 Nuxt `server/api`를 사용한다.
|
||||||
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
|
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
|
||||||
- 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다.
|
- 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다.
|
||||||
|
|
||||||
|
|||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:22-alpine AS dependencies
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
COPY --from=builder /app/.output ./.output
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
26
app.html
Normal file
26
app.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html {{ HTML_ATTRS }}>
|
||||||
|
<head {{ HEAD_ATTRS }}>
|
||||||
|
{{ HEAD }}
|
||||||
|
</head>
|
||||||
|
<body {{ BODY_ATTRS }}>
|
||||||
|
<div
|
||||||
|
id="site-splash"
|
||||||
|
class="site-splash"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="사이트를 불러오는 중"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
id="site-splash-logo"
|
||||||
|
class="site-splash__logo"
|
||||||
|
alt=""
|
||||||
|
width="80"
|
||||||
|
height="80"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<span id="site-splash-text" class="site-splash__text">sori.studio</span>
|
||||||
|
</div>
|
||||||
|
{{ APP }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
app.vue
Normal file
41
app.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup>
|
||||||
|
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from './lib/brand-color.js'
|
||||||
|
|
||||||
|
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
|
||||||
|
key: 'site-settings-public',
|
||||||
|
default: () => ({
|
||||||
|
title: 'sori.studio',
|
||||||
|
faviconUrl: '',
|
||||||
|
logoUrl: '',
|
||||||
|
logoText: '井',
|
||||||
|
brandColor: DEFAULT_BRAND_COLOR
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const siteAccentStyle = computed(() => ({
|
||||||
|
'--site-accent': normalizeBrandColor(appSiteSettings.value?.brandColor || DEFAULT_BRAND_COLOR)
|
||||||
|
}))
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div class="site-app" :style="siteAccentStyle">
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
364
assets/css/main.css
Normal file
364
assets/css/main.css
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-splash {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--site-bg);
|
||||||
|
transition: opacity 0.22s ease, visibility 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.site-app-ready .site-splash {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-splash__logo {
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-splash__text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.admin-settings-document,
|
||||||
|
body.admin-settings-document {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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-summary-clamp {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-summary-clamp--one {
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
line-clamp: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-summary-clamp--two {
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-summary-clamp--three {
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-bg);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 화면은 공개 사이트 테마와 분리된 라이트 UI로 고정한다.
|
||||||
|
*/
|
||||||
|
.admin-layout {
|
||||||
|
--site-bg: #f7f8fa;
|
||||||
|
--site-panel: #ffffff;
|
||||||
|
--site-panel-strong: #ffffff;
|
||||||
|
--site-text: #15171a;
|
||||||
|
--site-muted: #6b7280;
|
||||||
|
--site-soft: #657080;
|
||||||
|
--site-line: #e5e7eb;
|
||||||
|
--site-input: #ffffff;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout--light-controls input:not(.auth-form-input),
|
||||||
|
.admin-layout--light-controls textarea,
|
||||||
|
.admin-layout--light-controls select {
|
||||||
|
color: #15171a;
|
||||||
|
background-color: #ffffff;
|
||||||
|
caret-color: #15171a;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout--light-controls input:not(.auth-form-input)::placeholder,
|
||||||
|
.admin-layout--light-controls textarea::placeholder {
|
||||||
|
color: #8a94a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout--light-controls input:not(.auth-form-input):-webkit-autofill,
|
||||||
|
.admin-layout--light-controls input:not(.auth-form-input):-webkit-autofill:hover,
|
||||||
|
.admin-layout--light-controls input:not(.auth-form-input):-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: #15171a;
|
||||||
|
box-shadow: 0 0 0 1000px #ffffff inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
components/admin/AdminAdsSettingsCard.vue
Normal file
173
components/admin/AdminAdsSettingsCard.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<script setup>
|
||||||
|
const adSlots = [
|
||||||
|
{
|
||||||
|
key: 'adHomeFeedCode',
|
||||||
|
label: '메인 피드',
|
||||||
|
description: '메인 화면 Featured와 Latest 목록 사이에 표시됩니다.',
|
||||||
|
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adHomeInfeedCode',
|
||||||
|
label: '메인 인피드',
|
||||||
|
description: '메인 화면 Latest 게시물 목록 사이 한 곳에 무작위로 표시됩니다.',
|
||||||
|
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adSidebarCode',
|
||||||
|
label: '오른쪽 사이드',
|
||||||
|
description: '게시물 상세를 제외한 공개 화면의 오른쪽 사이드바 하단에 표시됩니다.',
|
||||||
|
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adPostSidebarCode',
|
||||||
|
label: '게시물 왼쪽 사이드',
|
||||||
|
description: '게시물 상세 화면의 왼쪽 사이드바 하단에 표시됩니다.',
|
||||||
|
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adPostTopCode',
|
||||||
|
label: '게시물 본문 상단',
|
||||||
|
description: '게시물 상세 본문 렌더링 직전에 표시됩니다.',
|
||||||
|
placeholder: '<script async src="..."><' + '/script>\n<ins class="adsbygoogle" ...></ins>'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adPostInArticleCode',
|
||||||
|
label: '게시물 인아티클',
|
||||||
|
description: '게시물 본문이 충분히 길 때 본문 중간 문단 뒤에 표시되며, 긴 글은 최대 두 번 표시됩니다.',
|
||||||
|
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adPostBottomCode',
|
||||||
|
label: '게시물 본문 하단',
|
||||||
|
description: '게시물 상세 본문 렌더링 직후에 표시됩니다.',
|
||||||
|
placeholder: '<ins class="adsbygoogle" ...></ins>\n<script>(adsbygoogle = window.adsbygoogle || []).push({})<' + '/script>'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 광고 슬롯 코드 등록 여부를 반환한다.
|
||||||
|
* @param {Object} form - 사이트 설정 폼
|
||||||
|
* @param {string} key - 슬롯 키
|
||||||
|
* @returns {boolean} 등록 여부
|
||||||
|
*/
|
||||||
|
const hasSlotCode = (form, key) => Boolean(String(form?.[key] || '').trim())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 광고 설정 카드
|
||||||
|
* @property {Object} form - 사이트 설정 폼 객체
|
||||||
|
* @property {boolean} editing - 편집 모드 여부
|
||||||
|
* @property {boolean} saving - 저장 중 여부
|
||||||
|
* @property {boolean} hasChanges - 변경 여부
|
||||||
|
*/
|
||||||
|
defineProps({
|
||||||
|
form: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
editing: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
saving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
hasChanges: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['begin', 'cancel', 'save'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="admin-settings-section-ads"
|
||||||
|
class="admin-ads-settings-card admin-settings-screen__card admin-settings-screen__card--ads relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
|
||||||
|
Ads
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
v-if="!editing"
|
||||||
|
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
|
||||||
|
>
|
||||||
|
위치별 광고 코드를 관리합니다. 비어 있는 슬롯은 공개 화면에 표시되지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
|
||||||
|
<template v-if="!editing">
|
||||||
|
<button
|
||||||
|
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('begin')"
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving || !hasChanges"
|
||||||
|
@click="$emit('save')"
|
||||||
|
>
|
||||||
|
{{ saving ? '저장 중' : '저장' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!editing"
|
||||||
|
class="admin-ads-settings-card__readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="slot in adSlots"
|
||||||
|
:key="slot.key"
|
||||||
|
class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[9rem_minmax(0,1fr)] md:items-center md:gap-5"
|
||||||
|
>
|
||||||
|
<p class="font-normal text-[#3f4650]">
|
||||||
|
{{ slot.label }}
|
||||||
|
</p>
|
||||||
|
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
|
||||||
|
{{ hasSlotCode(form, slot.key) ? '등록됨' : '미등록' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="admin-ads-settings-card__edit grid gap-5 border-t border-[#eceff2] pt-5"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="slot in adSlots"
|
||||||
|
:key="slot.key"
|
||||||
|
class="admin-settings-screen__field grid gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-[#3f4650]">{{ slot.label }}</span>
|
||||||
|
<p class="text-xs leading-relaxed text-[#657080]">
|
||||||
|
{{ slot.description }} 애드센스에서 제공한 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣습니다.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
v-model="form[slot.key]"
|
||||||
|
class="min-h-[9rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
rows="7"
|
||||||
|
spellcheck="false"
|
||||||
|
:placeholder="slot.placeholder"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
2561
components/admin/AdminBlockEditor.vue
Normal file
2561
components/admin/AdminBlockEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
478
components/admin/AdminEditorBlockPanel.vue
Normal file
478
components/admin/AdminEditorBlockPanel.vue
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
<script setup>
|
||||||
|
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
||||||
|
import {
|
||||||
|
CALLOUT_BACKGROUND_OPTIONS,
|
||||||
|
CALLOUT_BACKGROUND_SWATCHES,
|
||||||
|
CALLOUT_EMOJI_OPTIONS,
|
||||||
|
QUOTE_BACKGROUND_LABELS,
|
||||||
|
QUOTE_BACKGROUND_OPTIONS,
|
||||||
|
QUOTE_BACKGROUND_SWATCHES
|
||||||
|
} from '../../lib/markdown-callout.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** 패널 표시 여부 */
|
||||||
|
open: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/** 활성 블록 컨텍스트 */
|
||||||
|
panel: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'panel-focus-in',
|
||||||
|
'panel-focus-out',
|
||||||
|
'update-media-image',
|
||||||
|
'set-media-use-alt',
|
||||||
|
'move-gallery-image',
|
||||||
|
'remove-media-image',
|
||||||
|
'add-gallery-images',
|
||||||
|
'update-embed-url',
|
||||||
|
'update-quote-background',
|
||||||
|
'update-callout-options',
|
||||||
|
'update-code-options',
|
||||||
|
'update-toggle-options'
|
||||||
|
])
|
||||||
|
|
||||||
|
const backgroundLabels = {
|
||||||
|
gray: '회색',
|
||||||
|
blue: '파랑',
|
||||||
|
green: '초록',
|
||||||
|
yellow: '노랑',
|
||||||
|
red: '빨강',
|
||||||
|
purple: '보라'
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgroundSwatches = {
|
||||||
|
...CALLOUT_BACKGROUND_SWATCHES
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageOptions = ['', 'javascript', 'html', 'css', 'vue', 'json', 'bash', 'markdown', 'sql']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배경 라벨을 반환한다.
|
||||||
|
* @param {string} background - 배경 키
|
||||||
|
* @returns {string} 배경 라벨
|
||||||
|
*/
|
||||||
|
const getBackgroundLabel = (background) => backgroundLabels[background] || background
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배경 스와치를 반환한다.
|
||||||
|
* @param {string} background - 배경 키
|
||||||
|
* @returns {string} CSS 배경
|
||||||
|
*/
|
||||||
|
const getBackgroundSwatch = (background) => backgroundSwatches[background] || 'rgba(100,116,139,0.28)'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인용 배경 라벨을 반환한다.
|
||||||
|
* @param {string} background - 배경 키
|
||||||
|
* @returns {string} 배경 라벨
|
||||||
|
*/
|
||||||
|
const getQuoteBackgroundLabel = (background) => QUOTE_BACKGROUND_LABELS[background] || background
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인용 배경 스와치를 반환한다.
|
||||||
|
* @param {string} background - 배경 키
|
||||||
|
* @returns {string} CSS 배경
|
||||||
|
*/
|
||||||
|
const getQuoteBackgroundSwatch = (background) => QUOTE_BACKGROUND_SWATCHES[background] || QUOTE_BACKGROUND_SWATCHES.gray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록 종류 라벨
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const panelTitle = computed(() => {
|
||||||
|
if (!props.panel) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'gallery') {
|
||||||
|
return '갤러리'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'embed') {
|
||||||
|
return '임베드'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'quote') {
|
||||||
|
return '인용'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'callout') {
|
||||||
|
return '콜아웃'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'code') {
|
||||||
|
return '코드 블록'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'toggle') {
|
||||||
|
return '토글'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '이미지'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블록 종류 부제
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const panelMeta = computed(() => {
|
||||||
|
if (!props.panel) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'gallery') {
|
||||||
|
return `${props.panel.images.length}개 이미지`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'embed') {
|
||||||
|
return 'YouTube·X 등 URL'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'quote') {
|
||||||
|
return '인용 배경색'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'callout') {
|
||||||
|
return '제목·아이콘·배경색'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'code') {
|
||||||
|
return '언어·줄번호'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.panel.kind === 'toggle') {
|
||||||
|
return '기본 펼침 상태'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '커서가 위치한 이미지 줄'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
|
||||||
|
* @param {FocusEvent} event - focusout 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onPanelFocusOut = (event) => {
|
||||||
|
const root = event.currentTarget
|
||||||
|
const next = event.relatedTarget
|
||||||
|
|
||||||
|
if (next instanceof Node && root.contains(next)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('panel-focus-out')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="admin-editor-block-panel absolute inset-0 z-20 flex flex-col bg-white shadow-[-8px_0_24px_rgba(15,23,42,0.08)] transition-transform duration-300 ease-out"
|
||||||
|
:class="open ? 'translate-x-0' : 'translate-x-full pointer-events-none'"
|
||||||
|
:aria-hidden="!open"
|
||||||
|
@focusin="emit('panel-focus-in')"
|
||||||
|
@focusout="onPanelFocusOut"
|
||||||
|
>
|
||||||
|
<div v-if="panel" class="admin-editor-block-panel__inner flex h-full flex-col">
|
||||||
|
<header class="admin-editor-block-panel__header flex h-[56px] shrink-0 items-center justify-between border-b border-[#e3e6e8] px-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="admin-editor-block-panel__title text-xl font-bold text-black">
|
||||||
|
{{ panelTitle }}
|
||||||
|
</h2>
|
||||||
|
<p class="admin-editor-block-panel__meta mt-1 text-xs text-[#6b7280]">
|
||||||
|
{{ panelMeta }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="panel.kind === 'gallery'"
|
||||||
|
class="admin-editor-block-panel__add rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white"
|
||||||
|
type="button"
|
||||||
|
@click="emit('add-gallery-images')"
|
||||||
|
>
|
||||||
|
이미지 추가
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="admin-editor-block-panel__body flex-1 overflow-y-auto px-6 py-6">
|
||||||
|
<template v-if="panel.kind === 'embed'">
|
||||||
|
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||||
|
<span class="font-semibold text-[#394047]">임베드 URL</span>
|
||||||
|
<input
|
||||||
|
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
|
||||||
|
:value="panel.url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
@input="emit('update-embed-url', $event.target.value)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<p class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||||
|
YouTube·YouTube Shorts, X(트위터) 게시물 URL을 지원합니다. 그 외 URL은 링크 카드로 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="panel.kind === 'quote'">
|
||||||
|
<div class="admin-editor-block-panel__quote-settings grid gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-[#394047]">
|
||||||
|
배경색
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs leading-relaxed text-[#8e9cac]">
|
||||||
|
현재 인용 블록의 첫 줄에 배경 옵션을 추가합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="background in QUOTE_BACKGROUND_OPTIONS"
|
||||||
|
:key="`quote-background-${background}`"
|
||||||
|
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
|
||||||
|
:class="panel.quoteBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
|
||||||
|
type="button"
|
||||||
|
@click="emit('update-quote-background', background)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-5 shrink-0 rounded-full border border-black/5"
|
||||||
|
:style="{ background: getQuoteBackgroundSwatch(background) }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ getQuoteBackgroundLabel(background) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="panel.kind === 'callout'">
|
||||||
|
<div class="admin-editor-block-panel__callout-settings grid gap-5">
|
||||||
|
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||||
|
<span class="font-semibold text-[#394047]">제목</span>
|
||||||
|
<input
|
||||||
|
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
|
||||||
|
:value="panel.title"
|
||||||
|
type="text"
|
||||||
|
placeholder="주의사항"
|
||||||
|
@input="emit('update-callout-options', { title: $event.target.value })"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-center justify-between gap-3 rounded border border-[#edf0f2] bg-[#fafafa] px-3 py-3 text-sm font-semibold text-[#394047]">
|
||||||
|
<span>아이콘 표시</span>
|
||||||
|
<input
|
||||||
|
class="size-4 rounded border-[#c8ced3] text-[#15171a]"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="panel.calloutEmojiEnabled"
|
||||||
|
@change="emit('update-callout-options', { calloutEmojiEnabled: $event.target.checked })"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<p class="text-sm font-semibold text-[#394047]">
|
||||||
|
아이콘
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac] disabled:opacity-50"
|
||||||
|
:value="panel.calloutEmoji"
|
||||||
|
type="text"
|
||||||
|
maxlength="4"
|
||||||
|
placeholder="💡"
|
||||||
|
:disabled="!panel.calloutEmojiEnabled"
|
||||||
|
@input="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: $event.target.value })"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-5 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="emoji in CALLOUT_EMOJI_OPTIONS"
|
||||||
|
:key="`callout-emoji-${emoji}`"
|
||||||
|
class="grid h-10 place-items-center rounded border text-lg transition"
|
||||||
|
:class="panel.calloutEmoji === emoji && panel.calloutEmojiEnabled ? 'border-[#15171a] bg-white' : 'border-[#dce0e5] bg-[#fafafa] hover:bg-white'"
|
||||||
|
type="button"
|
||||||
|
:disabled="!panel.calloutEmojiEnabled"
|
||||||
|
@click="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: emoji })"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<p class="text-sm font-semibold text-[#394047]">
|
||||||
|
배경색
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="background in CALLOUT_BACKGROUND_OPTIONS"
|
||||||
|
:key="`callout-background-${background}`"
|
||||||
|
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
|
||||||
|
:class="panel.calloutBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
|
||||||
|
type="button"
|
||||||
|
@click="emit('update-callout-options', { calloutBackground: background })"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-5 shrink-0 rounded-full border border-black/5"
|
||||||
|
:style="{ background: getBackgroundSwatch(background) }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ getBackgroundLabel(background) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="panel.kind === 'code'">
|
||||||
|
<div class="admin-editor-block-panel__code-settings grid gap-4">
|
||||||
|
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||||
|
<span class="font-semibold text-[#394047]">언어</span>
|
||||||
|
<input
|
||||||
|
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
|
||||||
|
:value="panel.language"
|
||||||
|
type="text"
|
||||||
|
list="admin-editor-block-panel-languages"
|
||||||
|
placeholder="javascript"
|
||||||
|
@input="emit('update-code-options', { language: $event.target.value })"
|
||||||
|
>
|
||||||
|
<datalist id="admin-editor-block-panel-languages">
|
||||||
|
<option
|
||||||
|
v-for="language in languageOptions"
|
||||||
|
:key="`code-language-${language || 'plain'}`"
|
||||||
|
:value="language"
|
||||||
|
/>
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center justify-between gap-3 rounded border border-[#edf0f2] bg-[#fafafa] px-3 py-3 text-sm font-semibold text-[#394047]">
|
||||||
|
<span>줄번호 표시</span>
|
||||||
|
<input
|
||||||
|
class="size-4 rounded border-[#c8ced3] text-[#15171a]"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="panel.showLineNumbers"
|
||||||
|
@change="emit('update-code-options', { showLineNumbers: $event.target.checked })"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="panel.kind === 'toggle'">
|
||||||
|
<div class="admin-editor-block-panel__toggle-settings grid gap-3">
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
|
||||||
|
:class="panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
|
||||||
|
type="button"
|
||||||
|
@click="emit('update-toggle-options', { defaultOpen: true })"
|
||||||
|
>
|
||||||
|
<span>기본 펼침</span>
|
||||||
|
<span v-if="panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
|
||||||
|
:class="!panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
|
||||||
|
type="button"
|
||||||
|
@click="emit('update-toggle-options', { defaultOpen: false })"
|
||||||
|
>
|
||||||
|
<span>기본 닫힘</span>
|
||||||
|
<span v-if="!panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="admin-editor-block-panel__media-list grid gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(image, imageIndex) in panel.images"
|
||||||
|
:key="`block-panel-image-${imageIndex}`"
|
||||||
|
class="admin-editor-block-panel__media-row grid gap-3 rounded border border-[#edf0f2] bg-[#fafafa] p-3"
|
||||||
|
:class="panel.selectedImageIndex === imageIndex ? 'admin-editor-block-panel__media-row--selected' : ''"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="aspect-[16/10] w-full rounded bg-[#eff1f2] object-cover"
|
||||||
|
:src="image.url"
|
||||||
|
:alt="getImageAltAttribute(image)"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<label class="grid gap-1 text-xs font-semibold text-[#394047]">
|
||||||
|
캡션
|
||||||
|
<input
|
||||||
|
class="rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a] placeholder:font-normal placeholder:text-[#8e9cac]"
|
||||||
|
:value="image.caption || ''"
|
||||||
|
type="text"
|
||||||
|
placeholder="비우면 표시하지 않음"
|
||||||
|
@input="emit('update-media-image', imageIndex, { caption: $event.target.value })"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<label class="inline-flex cursor-pointer items-center gap-2 text-xs font-semibold text-[#394047]">
|
||||||
|
<input
|
||||||
|
class="size-3.5 rounded border-[#c8ced3] text-[#15171a]"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="image.useAlt"
|
||||||
|
@change="emit('set-media-use-alt', imageIndex, $event.target.checked)"
|
||||||
|
>
|
||||||
|
파일명을 캡션으로 사용
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="image.useAlt"
|
||||||
|
class="text-[11px] font-normal text-[#8e9cac]"
|
||||||
|
>
|
||||||
|
이미지 아래에 「{{ getImageDefaultAltLabel(image.url) || '파일명 없음' }}」을 표시합니다.
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-[11px] font-normal text-[#8e9cac]">
|
||||||
|
캡션을 비우면 이미지 아래에 아무 것도 표시하지 않습니다.
|
||||||
|
</p>
|
||||||
|
<label class="grid gap-1 text-xs font-semibold text-[#394047]">
|
||||||
|
이미지 URL
|
||||||
|
<input
|
||||||
|
class="rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a]"
|
||||||
|
:value="image.url"
|
||||||
|
type="text"
|
||||||
|
@input="emit('update-media-image', imageIndex, { url: $event.target.value })"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<div v-if="panel.kind === 'gallery'" class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:opacity-40"
|
||||||
|
type="button"
|
||||||
|
:disabled="imageIndex === 0"
|
||||||
|
@click="emit('move-gallery-image', imageIndex, -1)"
|
||||||
|
>
|
||||||
|
위로
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:opacity-40"
|
||||||
|
type="button"
|
||||||
|
:disabled="imageIndex === panel.images.length - 1"
|
||||||
|
@click="emit('move-gallery-image', imageIndex, 1)"
|
||||||
|
>
|
||||||
|
아래로
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
|
||||||
|
type="button"
|
||||||
|
@click="emit('remove-media-image', imageIndex)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
|
||||||
|
type="button"
|
||||||
|
@click="emit('remove-media-image', imageIndex)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-editor-block-panel__media-row--selected {
|
||||||
|
border-color: #2eb6ea;
|
||||||
|
box-shadow: 0 0 0 2px rgba(46, 182, 234, 0.18);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3332
components/admin/AdminMarkdownEditor.vue
Normal file
3332
components/admin/AdminMarkdownEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
107
components/admin/AdminMediaVideoThumbnail.vue
Normal file
107
components/admin/AdminMediaVideoThumbnail.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
/** 비디오 파일 URL */
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
/** 접근성 대체 텍스트 */
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
default: '비디오 썸네일'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const videoRef = ref(null)
|
||||||
|
const canvasRef = ref(null)
|
||||||
|
const thumbnailUrl = ref('')
|
||||||
|
const failed = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 프레임을 캔버스 이미지로 변환한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const captureVideoFrame = () => {
|
||||||
|
const video = videoRef.value
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
|
||||||
|
if (!video || !canvas || !video.videoWidth || !video.videoHeight) {
|
||||||
|
failed.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) {
|
||||||
|
failed.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
|
thumbnailUrl.value = canvas.toDataURL('image/jpeg', 0.78)
|
||||||
|
} catch {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메타데이터 로드 후 초반 프레임으로 이동한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const seekPreviewFrame = () => {
|
||||||
|
const video = videoRef.value
|
||||||
|
if (!video) {
|
||||||
|
failed.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Number.isFinite(video.duration) ? video.duration : 0
|
||||||
|
const targetTime = duration > 1 ? Math.min(1, duration * 0.1) : 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
video.currentTime = targetTime
|
||||||
|
} catch {
|
||||||
|
captureVideoFrame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="admin-media-video-thumbnail relative block aspect-square w-full overflow-hidden bg-surface">
|
||||||
|
<img
|
||||||
|
v-if="thumbnailUrl"
|
||||||
|
class="admin-media-video-thumbnail__image h-full w-full object-cover"
|
||||||
|
:src="thumbnailUrl"
|
||||||
|
:alt="alt"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="admin-media-video-thumbnail__fallback flex h-full w-full items-center justify-center text-xs font-bold uppercase tracking-[0.18em] text-muted"
|
||||||
|
>
|
||||||
|
video
|
||||||
|
</span>
|
||||||
|
<span class="admin-media-video-thumbnail__badge absolute bottom-1.5 left-1.5 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white">
|
||||||
|
video
|
||||||
|
</span>
|
||||||
|
<video
|
||||||
|
ref="videoRef"
|
||||||
|
class="hidden"
|
||||||
|
:src="src"
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
@loadedmetadata="seekPreviewFrame"
|
||||||
|
@seeked="captureVideoFrame"
|
||||||
|
@error="failed = true"
|
||||||
|
/>
|
||||||
|
<canvas ref="canvasRef" class="hidden" aria-hidden="true" />
|
||||||
|
<span v-if="failed && !thumbnailUrl" class="sr-only">
|
||||||
|
{{ alt }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
828
components/admin/AdminMemberForm.vue
Normal file
828
components/admin/AdminMemberForm.vue
Normal file
@@ -0,0 +1,828 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
member: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'edit'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['saved', 'deleted'])
|
||||||
|
|
||||||
|
const { toast, showToast } = useAdminToast()
|
||||||
|
|
||||||
|
const { data: adminSession } = await useFetch('/admin/api/auth/me', {
|
||||||
|
default: () => ({
|
||||||
|
userId: '',
|
||||||
|
roleCode: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isNewMember = computed(() => props.mode === 'new')
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const savedMemberSnapshot = ref('')
|
||||||
|
const avatarInputRef = ref(null)
|
||||||
|
const isUploadingAvatar = ref(false)
|
||||||
|
const isEditingMember = ref(props.mode === 'new')
|
||||||
|
const actionMenuOpen = ref(false)
|
||||||
|
const passwordModalOpen = ref(false)
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const isUpdatingPassword = ref(false)
|
||||||
|
const isDeletingMember = ref(false)
|
||||||
|
const actionError = ref('')
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: 'owner', label: '소유자' },
|
||||||
|
{ value: 'admin', label: '관리자' },
|
||||||
|
{ value: 'vip', label: 'VIP' },
|
||||||
|
{ value: 'member', label: '멤버' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
avatarUrl: '',
|
||||||
|
roleCode: 'member',
|
||||||
|
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.roleCode = member.roleCode || 'member'
|
||||||
|
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)
|
||||||
|
)])
|
||||||
|
|
||||||
|
const isFormEditable = computed(() => isNewMember.value || isEditingMember.value)
|
||||||
|
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
|
||||||
|
const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '')
|
||||||
|
const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value))
|
||||||
|
const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId)
|
||||||
|
&& String(props.member.id) === String(adminSession.value.userId))
|
||||||
|
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(props.member?.roleCode || form.roleCode))
|
||||||
|
const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value)
|
||||||
|
const canEditRoleSelect = computed(() => {
|
||||||
|
if (!isFormEditable.value || shouldRenderRoleAsText.value || isSaving.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAdminRoleCode.value === 'owner') {
|
||||||
|
return !isEditingSelf.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAdminRoleCode.value === 'admin') {
|
||||||
|
return !isTargetPrivilegedRole.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
const availableRoleOptions = computed(() => {
|
||||||
|
if (!canEditRoleSelect.value) {
|
||||||
|
return roleOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAdminRoleCode.value === 'admin') {
|
||||||
|
return roleOptions.filter((option) => ['vip', 'member'].includes(option.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return roleOptions
|
||||||
|
})
|
||||||
|
const roleHelpText = computed(() => {
|
||||||
|
if (!isFormEditable.value) {
|
||||||
|
return '수정하기를 누르면 변경 가능한 항목을 편집할 수 있습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRenderRoleAsText.value) {
|
||||||
|
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditingSelf.value && currentAdminRoleCode.value === 'owner') {
|
||||||
|
return '소유자는 본인 권한을 직접 낮출 수 없습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAdminRoleCode.value === 'admin' && isTargetPrivilegedRole.value) {
|
||||||
|
return '관리자는 소유자 또는 다른 관리자의 등급을 변경할 수 없습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAdminRoleCode.value === 'admin') {
|
||||||
|
return '관리자는 멤버와 VIP 등급만 변경할 수 있습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 저장 요청 본문을 문자열로 직렬화한다.
|
||||||
|
* @returns {string} 직렬화된 회원 입력값
|
||||||
|
*/
|
||||||
|
const serializeMemberPayload = () => JSON.stringify({
|
||||||
|
...getMemberPayload(),
|
||||||
|
roleCode: form.roleCode
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @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 shouldBlockUnsavedMemberChanges = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value)
|
||||||
|
const canSubmitMemberForm = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value && !isSaving.value)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isUnsavedModalOpen,
|
||||||
|
stayOnUnsavedPage,
|
||||||
|
leaveUnsavedPage
|
||||||
|
} = useAdminUnsavedChangesGuard(shouldBlockUnsavedMemberChanges)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 상세를 수정 모드로 전환한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const enterEditMode = () => {
|
||||||
|
isEditingMember.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 썸네일 파일 선택창을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openAvatarFilePicker = () => {
|
||||||
|
if (!isFormEditable.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFormEditable.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploadingAvatar.value = true
|
||||||
|
|
||||||
|
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()
|
||||||
|
showToast('success', '썸네일이 변경되었습니다.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
isUploadingAvatar.value = false
|
||||||
|
if (target) {
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 썸네일 연결을 제거한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const removeAvatar = () => {
|
||||||
|
if (!isFormEditable.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ''
|
||||||
|
actionError.value = ''
|
||||||
|
passwordModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openDeleteModal = () => {
|
||||||
|
deleteForm.confirmText = ''
|
||||||
|
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 () => {
|
||||||
|
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 = ''
|
||||||
|
showToast('success', '비밀번호가 변경되었습니다.')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||||
|
actionError.value = message
|
||||||
|
showToast('error', message)
|
||||||
|
} finally {
|
||||||
|
isUpdatingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 권한으로 회원을 삭제한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteMember = async () => {
|
||||||
|
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) {
|
||||||
|
const message = error?.data?.message || '회원 삭제에 실패했습니다.'
|
||||||
|
actionError.value = message
|
||||||
|
showToast('error', message)
|
||||||
|
} finally {
|
||||||
|
isDeletingMember.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 기본 정보를 저장한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const saveMember = async () => {
|
||||||
|
if (isSaving.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFormEditable.value) {
|
||||||
|
enterEditMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUnsavedMemberChanges.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = getMemberPayload()
|
||||||
|
if (!isNewMember.value && canEditRoleSelect.value && form.roleCode !== props.member?.roleCode) {
|
||||||
|
await $fetch(`/admin/api/members/${props.member.id}/role`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
role: form.roleCode
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
isEditingMember.value = isNewMember.value
|
||||||
|
showToast('success', '저장되었습니다.')
|
||||||
|
} catch (error) {
|
||||||
|
showToast('error', 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:cursor-not-allowed disabled:bg-[#c7cdd4] disabled:text-white"
|
||||||
|
type="button"
|
||||||
|
:disabled="isFormEditable && !canSubmitMemberForm"
|
||||||
|
@click="saveMember"
|
||||||
|
>
|
||||||
|
{{ !isFormEditable ? '수정하기' : 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 ? '썸네일 변경' : '썸네일 등록'"
|
||||||
|
:disabled="!isFormEditable"
|
||||||
|
:class="{ 'cursor-default': !isFormEditable }"
|
||||||
|
@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">
|
||||||
|
{{ !isFormEditable ? '현재 썸네일' : isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="form.avatarUrl && isFormEditable"
|
||||||
|
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>
|
||||||
|
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
|
||||||
|
{{ form.username || '-' }}
|
||||||
|
</span>
|
||||||
|
<input v-else v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] 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>
|
||||||
|
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
|
||||||
|
{{ form.email || '-' }}
|
||||||
|
</span>
|
||||||
|
<input v-else v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] 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>
|
||||||
|
<span
|
||||||
|
v-if="shouldRenderRoleAsText"
|
||||||
|
class="admin-member-form__role-text flex min-h-12 w-full items-center text-sm font-semibold text-[#15171a]"
|
||||||
|
:class="{ 'rounded-md border border-[#d7dce0] bg-white px-4': isFormEditable }"
|
||||||
|
>
|
||||||
|
{{ currentRoleLabel }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="admin-member-form__select-wrap relative block">
|
||||||
|
<select
|
||||||
|
v-model="form.roleCode"
|
||||||
|
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-[#d7dce0] bg-white px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
|
||||||
|
:disabled="!canEditRoleSelect"
|
||||||
|
>
|
||||||
|
<option v-for="option in availableRoleOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<svg class="pointer-events-none absolute right-4 top-1/2 size-4 -translate-y-1/2 text-[#394047]" 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="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="admin-member-form__hint mt-2 block text-sm text-[#8a95a5]">
|
||||||
|
{{ roleHelpText }}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
|
||||||
|
{{ form.labelsText || '-' }}
|
||||||
|
</span>
|
||||||
|
<input v-else v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] 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>
|
||||||
|
<p v-if="!isFormEditable" class="admin-member-form__readonly min-h-24 whitespace-pre-wrap text-sm leading-6 text-[#15171a]">
|
||||||
|
{{ form.note || '-' }}
|
||||||
|
</p>
|
||||||
|
<textarea v-else v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-[#d7dce0] bg-white px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] 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>
|
||||||
|
</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="toast"
|
||||||
|
class="admin-member-form__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-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<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>
|
||||||
548
components/admin/AdminPageForm.vue
Normal file
548
components/admin/AdminPageForm.vue
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
initialPage: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
saving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
deleting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
canViewPage: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
publicUrl: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
showDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'delete'])
|
||||||
|
|
||||||
|
const slugTouched = ref(Boolean(props.initialPage.slug))
|
||||||
|
const blockEditor = ref(null)
|
||||||
|
const htmlEditor = ref(null)
|
||||||
|
const editorMode = ref('write')
|
||||||
|
const isUploadingPageAsset = ref(false)
|
||||||
|
const isSettingsOpen = ref(true)
|
||||||
|
const savedPageSnapshot = ref('')
|
||||||
|
const htmlCursorRange = reactive({
|
||||||
|
start: 0,
|
||||||
|
end: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultHtmlDocument = `<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Landing</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: props.initialPage.title || '',
|
||||||
|
slug: props.initialPage.slug || '',
|
||||||
|
status: props.initialPage.status || 'published',
|
||||||
|
renderMode: props.initialPage.renderMode || 'html_document',
|
||||||
|
content: props.initialPage.content || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한글 음절 1자를 영문 표기로 변환
|
||||||
|
* @param {string} char - 변환할 문자
|
||||||
|
* @returns {string} 영문 표기
|
||||||
|
*/
|
||||||
|
const romanizeHangulSyllable = (char) => {
|
||||||
|
const syllableCode = char.charCodeAt(0)
|
||||||
|
const hangulBase = 0xac00
|
||||||
|
const hangulLast = 0xd7a3
|
||||||
|
|
||||||
|
if (syllableCode < hangulBase || syllableCode > hangulLast) {
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
|
||||||
|
const choseong = ['g', 'kk', 'n', 'd', 'tt', 'r', 'm', 'b', 'pp', 's', 'ss', '', 'j', 'jj', 'ch', 'k', 't', 'p', 'h']
|
||||||
|
const jungseong = ['a', 'ae', 'ya', 'yae', 'eo', 'e', 'yeo', 'ye', 'o', 'wa', 'wae', 'oe', 'yo', 'u', 'wo', 'we', 'wi', 'yu', 'eu', 'ui', 'i']
|
||||||
|
const jongseong = ['', 'k', 'k', 'ks', 'n', 'nj', 'nh', 't', 'l', 'lk', 'lm', 'lb', 'ls', 'lt', 'lp', 'lh', 'm', 'p', 'ps', 't', 't', 'ng', 't', 't', 'k', 't', 'p', 'h']
|
||||||
|
|
||||||
|
const offset = syllableCode - hangulBase
|
||||||
|
const choseongIndex = Math.floor(offset / 588)
|
||||||
|
const jungseongIndex = Math.floor((offset % 588) / 28)
|
||||||
|
const jongseongIndex = offset % 28
|
||||||
|
|
||||||
|
return `${choseong[choseongIndex]}${jungseong[jungseongIndex]}${jongseong[jongseongIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자열을 영문 URL 슬러그로 변환
|
||||||
|
* @param {string} value - 원본 문자열
|
||||||
|
* @returns {string} 영문 슬러그
|
||||||
|
*/
|
||||||
|
const toSlug = (value) => value
|
||||||
|
.normalize('NFC')
|
||||||
|
.split('')
|
||||||
|
.map((char) => romanizeHangulSyllable(char))
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
const pageSlug = computed(() => toSlug(form.slug || form.title))
|
||||||
|
const viewPageUrl = computed(() => props.publicUrl || (pageSlug.value ? `/pages/${pageSlug.value}` : ''))
|
||||||
|
const pageUrlHint = computed(() => `/pages/${pageSlug.value || 'page-slug'}/`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 폼의 저장 비교용 문자열을 생성한다.
|
||||||
|
* @returns {string} 직렬화된 폼 상태
|
||||||
|
*/
|
||||||
|
const serializePageForm = () => JSON.stringify({
|
||||||
|
title: form.title.trim(),
|
||||||
|
slug: pageSlug.value,
|
||||||
|
status: form.status,
|
||||||
|
renderMode: form.renderMode,
|
||||||
|
content: form.content
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasUnsavedPageChanges = computed(() => serializePageForm() !== savedPageSnapshot.value)
|
||||||
|
|
||||||
|
const headerStatusText = computed(() => {
|
||||||
|
if (props.saving) {
|
||||||
|
return 'Saving...'
|
||||||
|
}
|
||||||
|
if (hasUnsavedPageChanges.value) {
|
||||||
|
return 'Unsaved changes'
|
||||||
|
}
|
||||||
|
if (props.initialPage.id) {
|
||||||
|
return 'Saved'
|
||||||
|
}
|
||||||
|
return 'New page'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => form.title, (title) => {
|
||||||
|
if (!slugTouched.value) {
|
||||||
|
form.slug = toSlug(title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 슬러그 직접 입력 상태 표시
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const touchSlug = () => {
|
||||||
|
slugTouched.value = true
|
||||||
|
form.slug = toSlug(form.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 설정 패널을 토글한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleSettingsPanel = () => {
|
||||||
|
isSettingsOpen.value = !isSettingsOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목 입력 후 본문 에디터로 이동
|
||||||
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const focusContentEditor = (event) => {
|
||||||
|
event?.preventDefault()
|
||||||
|
|
||||||
|
if (form.renderMode === 'html_document') {
|
||||||
|
htmlEditor.value?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
blockEditor.value?.focusFirstBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 작성 모드를 변경한다.
|
||||||
|
* @param {'markdown'|'html_document'} mode - 페이지 작성 모드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const setRenderMode = (mode) => {
|
||||||
|
form.renderMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML textarea 커서 위치를 기억한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const rememberHtmlCursor = () => {
|
||||||
|
if (!htmlEditor.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlCursorRange.start = htmlEditor.value.selectionStart ?? form.content.length
|
||||||
|
htmlCursorRange.end = htmlEditor.value.selectionEnd ?? htmlCursorRange.start
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 본문 커서 위치에 텍스트를 삽입한다.
|
||||||
|
* @param {string} text - 삽입할 텍스트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const insertTextAtHtmlCursor = async (text) => {
|
||||||
|
const start = Math.max(0, htmlCursorRange.start)
|
||||||
|
const end = Math.max(start, htmlCursorRange.end)
|
||||||
|
|
||||||
|
form.content = `${form.content.slice(0, start)}${text}${form.content.slice(end)}`
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const nextCursor = start + text.length
|
||||||
|
htmlEditor.value?.focus()
|
||||||
|
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
|
||||||
|
htmlCursorRange.start = nextCursor
|
||||||
|
htmlCursorRange.end = nextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 기본 문서 골격을 현재 본문에 채운다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const completeHtmlDocumentSkeleton = async () => {
|
||||||
|
form.content = defaultHtmlDocument
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const bodyIndex = form.content.indexOf('</body>')
|
||||||
|
const nextCursor = bodyIndex > -1 ? bodyIndex : form.content.length
|
||||||
|
htmlEditor.value?.focus()
|
||||||
|
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
|
||||||
|
htmlCursorRange.start = nextCursor
|
||||||
|
htmlCursorRange.end = nextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML textarea에서 VS Code식 기본 골격 단축 입력을 처리한다.
|
||||||
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const handleHtmlEditorKeydown = async (event) => {
|
||||||
|
if (event.key !== 'Tab' || form.renderMode !== 'html_document') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = form.content.trim()
|
||||||
|
if (content !== '' && content !== '!') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
await completeHtmlDocumentSkeleton()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다.
|
||||||
|
* @param {Event} event - 파일 입력 이벤트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const uploadPageAsset = async (event) => {
|
||||||
|
const files = event.target.files
|
||||||
|
|
||||||
|
if (!files?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberHtmlCursor()
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('files', files[0])
|
||||||
|
isUploadingPageAsset.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await $fetch('/admin/api/uploads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
const uploadedUrl = result.files?.[0]?.url || ''
|
||||||
|
|
||||||
|
if (uploadedUrl && form.renderMode === 'html_document') {
|
||||||
|
await insertTextAtHtmlCursor(uploadedUrl)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
event.target.value = ''
|
||||||
|
isUploadingPageAsset.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 입력값을 생성한다.
|
||||||
|
* @returns {Object} 페이지 입력값
|
||||||
|
*/
|
||||||
|
const createPayload = () => ({
|
||||||
|
title: form.title.trim(),
|
||||||
|
slug: pageSlug.value,
|
||||||
|
status: form.status,
|
||||||
|
renderMode: form.renderMode,
|
||||||
|
content: form.content,
|
||||||
|
featuredImage: null
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 입력값 제출
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const submitPage = () => {
|
||||||
|
emit('submit', createPayload())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 폼 상태를 저장 완료 기준점으로 표시한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const markSaved = () => {
|
||||||
|
savedPageSnapshot.value = serializePageForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(markSaved)
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
markSaved
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="admin-page-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="submitPage">
|
||||||
|
<div class="admin-page-form__workspace flex min-w-0 flex-1 flex-col bg-white">
|
||||||
|
<header class="admin-page-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
|
||||||
|
<div class="admin-page-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
|
||||||
|
<div class="admin-page-form__toolbar-left flex h-full min-w-0 flex-1 items-center gap-3">
|
||||||
|
<NuxtLink class="admin-page-form__toolbar-link inline-flex shrink-0 items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black" to="/admin/pages">
|
||||||
|
<span class="admin-page-form__toolbar-back text-lg leading-none" aria-hidden="true"><</span>
|
||||||
|
<span>Pages</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="canViewPage && viewPageUrl"
|
||||||
|
class="admin-page-form__toolbar-status-link inline-flex items-center gap-1 truncate rounded px-2 py-1.5 text-sm font-medium text-[#8E9CAC] transition-colors hover:bg-[#f1f3f4] hover:text-[#394047]"
|
||||||
|
:to="viewPageUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span>View page</span>
|
||||||
|
<span aria-hidden="true">↗</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="admin-page-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8E9CAC]">
|
||||||
|
{{ headerStatusText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-page-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
|
||||||
|
<div
|
||||||
|
v-show="form.renderMode === 'markdown'"
|
||||||
|
id="admin-page-form-mode-toggle-host"
|
||||||
|
class="admin-page-form__mode-toggle-host flex shrink-0 items-center"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="admin-page-form__toolbar-save rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
|
||||||
|
type="submit"
|
||||||
|
:disabled="saving || !form.title.trim() || !hasUnsavedPageChanges"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="admin-page-form__settings-toggle grid size-[34px] place-items-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="isSettingsOpen"
|
||||||
|
aria-label="페이지 설정 패널 전환"
|
||||||
|
@click="toggleSettingsPanel"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2.16699C14.3242 2.16699 14.4998 2.39365 14.5 2.57129V13.4287C14.5 13.606 14.3242 13.834 14 13.834H11.5V2.16699H14ZM2 2.16699H10.5V13.834H2C1.6756 13.834 1.5 13.6064 1.5 13.4287V2.57129C1.50024 2.39409 1.67607 2.16699 2 2.16699Z" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="admin-page-form__editor-scroll min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<section class="admin-page-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-24">
|
||||||
|
<input
|
||||||
|
v-model="form.title"
|
||||||
|
class="admin-page-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-3xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
|
||||||
|
type="text"
|
||||||
|
placeholder="제목"
|
||||||
|
@keydown.enter="focusContentEditor"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div v-if="form.renderMode === 'markdown'" class="admin-page-form__field admin-page-form__content-editor text-sm">
|
||||||
|
<AdminMarkdownEditor
|
||||||
|
ref="blockEditor"
|
||||||
|
v-model="form.content"
|
||||||
|
v-model:editor-mode="editorMode"
|
||||||
|
mode-toggle-teleport-to="#admin-page-form-mode-toggle-host"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label v-else class="admin-page-form__field admin-page-form__html-field grid gap-2 text-sm">
|
||||||
|
<span class="admin-page-form__label sr-only">HTML 문서</span>
|
||||||
|
<textarea
|
||||||
|
ref="htmlEditor"
|
||||||
|
v-model="form.content"
|
||||||
|
class="admin-page-form__html-editor min-h-[68vh] w-full resize-y rounded border border-[#e3e6e8] bg-white px-4 py-4 font-mono text-sm leading-6 text-[#15171a] outline-none placeholder:text-[#8e9cac] focus:border-[#8e9cac]"
|
||||||
|
spellcheck="false"
|
||||||
|
@blur="rememberHtmlCursor"
|
||||||
|
@click="rememberHtmlCursor"
|
||||||
|
@focus="rememberHtmlCursor"
|
||||||
|
@input="rememberHtmlCursor"
|
||||||
|
@keydown="handleHtmlEditorKeydown"
|
||||||
|
@keyup="rememberHtmlCursor"
|
||||||
|
@select="rememberHtmlCursor"
|
||||||
|
:placeholder="defaultHtmlDocument"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="admin-page-form__settings flex h-screen shrink-0 flex-col overflow-hidden border-[#e3e6e8] bg-white transition-[width,border-color] duration-300 ease-out"
|
||||||
|
:class="isSettingsOpen ? 'w-[420px] border-l' : 'w-0 border-l-0'"
|
||||||
|
:aria-hidden="!isSettingsOpen"
|
||||||
|
>
|
||||||
|
<div class="admin-page-form__settings-inner relative flex h-full w-[420px] flex-col">
|
||||||
|
<div class="admin-page-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
|
||||||
|
<h2 class="admin-page-form__settings-title text-xl font-bold text-black">
|
||||||
|
페이지 설정
|
||||||
|
</h2>
|
||||||
|
<button class="admin-page-form__settings-close grid size-8 place-items-center rounded text-neutral-900 transition-colors hover:bg-[#eff1f2] hover:text-neutral-500" type="button" aria-label="페이지 설정 닫기" @click="toggleSettingsPanel">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="16" width="16" class="h-[1.1rem] w-[1.1rem]" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-width="0.4" fill="currentColor" stroke="#000000" stroke-linejoin="round" d="M.44,21.44a1.49,1.49,0,0,0,0,2.12,1.5,1.5,0,0,0,2.12,0l9.26-9.26a.25.25,0,0,1,.36,0l9.26,9.26a1.5,1.5,0,0,0,2.12,0,1.49,1.49,0,0,0,0-2.12L14.3,12.18a.25.25,0,0,1,0-.36l9.26-9.26A1.5,1.5,0,0,0,21.44.44L12.18,9.7a.25.25,0,0,1-.36,0L2.56.44A1.5,1.5,0,0,0,.44,2.56L9.7,11.82a.25.25,0,0,1,0,.36Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-page-form__settings-body grid flex-1 content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
|
||||||
|
<div class="admin-page-form__field grid gap-1 text-sm">
|
||||||
|
<div class="admin-page-form__page-url-header flex h-[22px] items-center justify-between">
|
||||||
|
<span class="admin-page-form__label font-bold text-[#15171a]">Page URL</span>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="canViewPage && viewPageUrl"
|
||||||
|
class="admin-page-form__view-page inline-flex items-center gap-1 rounded px-3 py-1.5 text-xs text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
|
||||||
|
:to="viewPageUrl"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span>View Page</span>
|
||||||
|
<span aria-hidden="true">↗</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<label class="admin-page-form__page-url-input flex h-[38px] items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
|
||||||
|
<span class="admin-page-form__page-url-icon text-sm text-[#394047]" aria-hidden="true">⌘</span>
|
||||||
|
<input
|
||||||
|
v-model="form.slug"
|
||||||
|
class="admin-page-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none"
|
||||||
|
type="text"
|
||||||
|
pattern="[a-z0-9]+(-[a-z0-9]+)*"
|
||||||
|
required
|
||||||
|
@input="touchSlug"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<p class="admin-page-form__page-url-hint text-xs text-[#7c8b9a]">
|
||||||
|
{{ pageUrlHint }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="admin-page-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-page-form__label font-medium">상태</span>
|
||||||
|
<span class="admin-page-form__select-wrap relative block">
|
||||||
|
<select v-model="form.status" class="admin-page-form__select h-[38px] w-full appearance-none rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-10 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
|
||||||
|
<option value="draft">초안</option>
|
||||||
|
<option value="published">공개</option>
|
||||||
|
<option value="private">비공개</option>
|
||||||
|
</select>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span v-if="form.status === 'draft'" class="admin-page-form__hint text-xs text-muted">
|
||||||
|
초안 페이지는 공개 URL에서 보이지 않습니다.
|
||||||
|
</span>
|
||||||
|
<span v-else-if="form.status === 'private'" class="admin-page-form__hint text-xs text-muted">
|
||||||
|
비공개 페이지는 공개 URL에서 보이지 않습니다.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="admin-page-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-page-form__label font-medium">페이지 형식</span>
|
||||||
|
<div class="admin-page-form__mode-control grid grid-cols-2 rounded border border-[#e3e6e8] bg-[#eff1f2] p-1">
|
||||||
|
<button
|
||||||
|
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
|
||||||
|
:class="form.renderMode === 'markdown' ? 'bg-[#15171a] text-white' : 'text-[#7c8b9a] hover:bg-white hover:text-[#15171a]'"
|
||||||
|
type="button"
|
||||||
|
@click="setRenderMode('markdown')"
|
||||||
|
>
|
||||||
|
일반 텍스트
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
|
||||||
|
:class="form.renderMode === 'html_document' ? 'bg-[#15171a] text-white' : 'text-[#7c8b9a] hover:bg-white hover:text-[#15171a]'"
|
||||||
|
type="button"
|
||||||
|
@click="setRenderMode('html_document')"
|
||||||
|
>
|
||||||
|
HTML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.renderMode === 'html_document'" class="admin-page-form__field grid gap-2 text-sm">
|
||||||
|
<span class="admin-page-form__label font-medium">HTML 자산</span>
|
||||||
|
<label
|
||||||
|
class="admin-page-form__asset-upload inline-flex h-10 cursor-pointer items-center justify-center rounded bg-[#15171a] px-3 text-sm font-semibold text-white transition-colors hover:bg-black"
|
||||||
|
:class="{ 'pointer-events-none opacity-50': isUploadingPageAsset }"
|
||||||
|
>
|
||||||
|
{{ isUploadingPageAsset ? '업로드 중' : '파일 업로드' }}
|
||||||
|
<input
|
||||||
|
class="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*,audio/*,.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx"
|
||||||
|
@change="uploadPageAsset"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<p class="admin-page-form__asset-upload-hint text-xs leading-5 text-[#7c8b9a]">
|
||||||
|
HTML 모드에서는 업로드된 파일 URL을 현재 커서 위치에 삽입합니다. 예: <img src="여기">
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showDelete" class="admin-page-form__settings-footer border-t border-[#e3e6e8] p-6">
|
||||||
|
<button
|
||||||
|
class="admin-page-form__delete-button flex h-[44px] w-full items-center justify-center gap-2 rounded border border-[#d7dce0] bg-white text-sm font-bold text-[#394047] transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="emit('delete')"
|
||||||
|
>
|
||||||
|
<svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 7h16M10 11v6M14 11v6M6 7l1 14h10l1-14M9 7V4h6v3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ deleting ? 'Deleting page' : 'Delete page' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
57
components/admin/AdminPostExportFileRow.vue
Normal file
57
components/admin/AdminPostExportFileRow.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 게시물 Export 파일 선택 행
|
||||||
|
* @property {Object} file - Export 파일
|
||||||
|
* @property {boolean} selected - 선택 여부
|
||||||
|
* @property {boolean} disabled - 선택 비활성 여부
|
||||||
|
*/
|
||||||
|
defineProps({
|
||||||
|
file: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['toggle'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-post-export-file-row grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-[#edf0f3] px-3 py-2 last:border-b-0">
|
||||||
|
<button
|
||||||
|
class="admin-post-export-file-row__check inline-flex size-4 items-center justify-center rounded border border-[#cfd6de] bg-white transition focus:outline-none focus:ring-2 focus:ring-[#15171a] focus:ring-offset-1 disabled:cursor-not-allowed disabled:bg-[#f4f6f8]"
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
:aria-checked="selected"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="`${file.fileName} 선택`"
|
||||||
|
@click="$emit('toggle')"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="selected"
|
||||||
|
class="admin-post-export-file-row__check-mark block size-2 rounded-sm bg-[#15171a]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div class="admin-post-export-file-row__body min-w-0">
|
||||||
|
<p class="admin-post-export-file-row__name truncate text-sm font-medium text-[#15171a]">
|
||||||
|
{{ file.fileName }}
|
||||||
|
</p>
|
||||||
|
<p class="admin-post-export-file-row__range mt-0.5 text-xs text-[#9aa3ad]">
|
||||||
|
{{ file.postStart }}-{{ file.postEnd }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="file.status !== 'ready' || !file.filePath"
|
||||||
|
class="admin-post-export-file-row__status inline-flex h-8 items-center justify-center rounded px-2 text-xs font-semibold text-[#a6b0bb]"
|
||||||
|
>
|
||||||
|
{{ file.status === 'processing' ? '생성 중' : file.status === 'failed' ? '실패' : '다운로드 대기' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
2373
components/admin/AdminPostForm.vue
Normal file
2373
components/admin/AdminPostForm.vue
Normal file
File diff suppressed because it is too large
Load Diff
193
components/admin/AdminRowMoreMenu.vue
Normal file
193
components/admin/AdminRowMoreMenu.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup>
|
||||||
|
const openMenuId = defineModel('openMenuId', {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** 이 메뉴 인스턴스의 고유 id */
|
||||||
|
itemId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
/** 트리거 비활성화 */
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/** 처리 중(… 표시) */
|
||||||
|
busy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/** 접근성 라벨 접두사 */
|
||||||
|
menuLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '메뉴'
|
||||||
|
},
|
||||||
|
/** 트리거 크기: md(테이블) | sm(배지·사이드) */
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md'
|
||||||
|
},
|
||||||
|
/** 어두운 배경 위 트리거(미디어 폴더 선택 행 등) */
|
||||||
|
inverse: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const triggerRef = ref(null)
|
||||||
|
const popoverStyle = ref({})
|
||||||
|
|
||||||
|
/** @type {import('vue').ComputedRef<boolean>} */
|
||||||
|
const isOpen = computed(() => openMenuId.value === props.itemId)
|
||||||
|
|
||||||
|
/** @type {import('vue').ComputedRef<string>} */
|
||||||
|
const triggerSizeClass = computed(() => (props.size === 'sm' ? 'size-7' : 'size-9'))
|
||||||
|
|
||||||
|
/** @type {import('vue').ComputedRef<string>} */
|
||||||
|
const iconSizeClass = computed(() => (props.size === 'sm' ? 'size-5' : 'size-6'))
|
||||||
|
|
||||||
|
/** @type {import('vue').ComputedRef<string>} */
|
||||||
|
const triggerToneClass = computed(() => (
|
||||||
|
props.inverse
|
||||||
|
? 'text-white hover:bg-white/15 focus-visible:ring-white/40'
|
||||||
|
: 'text-[#394047] hover:bg-[#eceff2]'
|
||||||
|
))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 행 메뉴 위치를 화면 기준으로 계산한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updatePopoverPosition = () => {
|
||||||
|
if (!import.meta.client || !triggerRef.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = triggerRef.value.getBoundingClientRect()
|
||||||
|
const menuWidth = props.size === 'sm' ? 176 : 176
|
||||||
|
const estimatedHeight = 112
|
||||||
|
const margin = 8
|
||||||
|
const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin))
|
||||||
|
const opensUp = rect.bottom + estimatedHeight + margin > window.innerHeight
|
||||||
|
const top = opensUp
|
||||||
|
? Math.max(margin, rect.top - estimatedHeight - 4)
|
||||||
|
: rect.bottom + 4
|
||||||
|
|
||||||
|
popoverStyle.value = {
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
width: `${menuWidth}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 열기/닫기
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleMenu = () => {
|
||||||
|
if (props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openMenuId.value = isOpen.value ? '' : props.itemId
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, async (open) => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
updatePopoverPosition()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', updatePopoverPosition)
|
||||||
|
window.addEventListener('scroll', updatePopoverPosition, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', updatePopoverPosition)
|
||||||
|
window.removeEventListener('scroll', updatePopoverPosition, true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="admin-row-more-menu relative inline-flex justify-end"
|
||||||
|
data-admin-row-menu
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
ref="triggerRef"
|
||||||
|
class="admin-row-more-menu__trigger inline-flex items-center justify-center rounded transition-colors focus-visible:outline focus-visible:ring-2 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
|
||||||
|
:class="[triggerSizeClass, triggerToneClass]"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-label="isOpen ? `${menuLabel} 닫기` : menuLabel"
|
||||||
|
@mousedown.stop
|
||||||
|
@click.stop="toggleMenu"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="busy"
|
||||||
|
class="text-[10px] font-semibold text-muted"
|
||||||
|
aria-hidden="true"
|
||||||
|
>…</span>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="admin-row-more-menu__icon shrink-0"
|
||||||
|
:class="iconSizeClass"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="admin-row-more-menu__popover fixed z-[80] overflow-hidden rounded-xl border border-[#e2e5e9] bg-white py-2 text-sm text-[#3f4650] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
|
||||||
|
:style="popoverStyle"
|
||||||
|
role="menu"
|
||||||
|
data-admin-row-menu
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
color: #3f4650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item:hover) {
|
||||||
|
background: #f3f5f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item:disabled) {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item--danger) {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item--danger:hover) {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
185
components/admin/AdminSettingsNavIcon.vue
Normal file
185
components/admin/AdminSettingsNavIcon.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 관리자 사이트 설정 좌측 내비 아이콘
|
||||||
|
* @property {string} [iconId] - 아이콘 식별자. 미지정·미구현 시 자리 표시(placeholder)만 렌더
|
||||||
|
*/
|
||||||
|
defineProps({
|
||||||
|
iconId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="admin-settings-nav-icon inline-flex shrink-0 items-center justify-center text-current"
|
||||||
|
:class="iconId ? `admin-settings-nav-icon--${iconId}` : 'admin-settings-nav-icon--placeholder'"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<!-- 블로그 제목·설명 -->
|
||||||
|
<svg
|
||||||
|
v-if="iconId === 'title-desc'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="-0.75 -0.75 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M2.109375 6.32625h18.28125s1.40625 0 1.40625 1.40625v7.03125s0 1.40625 -1.40625 1.40625H2.109375s-1.40625 0 -1.40625 -1.40625v-7.03125s0 -1.40625 1.40625 -1.40625" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m16.171875 17.57625 0 -12.65625" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M11.953125 21.795a4.21875 4.21875 0 0 0 4.21875 -4.21875 4.21875 4.21875 0 0 0 4.21875 4.21875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M11.953125 0.70125a4.21875 4.21875 0 0 1 4.21875 4.21875 4.21875 4.21875 0 0 1 4.21875 -4.21875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 타임존 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'timezone'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="-0.75 -0.75 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M10.546875 16.171875a5.625 5.625 0 1 0 11.25 0 5.625 5.625 0 1 0 -11.25 0Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m18.658125000000002 16.171875 -2.48625 0 0 -2.4853125" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M9.838125 21.703125a10.5478125 10.5478125 0 1 1 11.866875 -11.85375" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M8.7084375 21.4884375C7.2825 19.3959375 6.328125 15.593437499999999 6.328125 11.25S7.2825 3.105 8.7084375 1.0115625" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m0.7265625 10.546875 8.9278125 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M2.8115625 4.921875 19.6875 4.921875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m1.92 16.171875 5.814375 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M13.7915625 1.0115625a15.9215625 15.9215625 0 0 1 2.15625 6.69" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 메인 화면 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'home-cover'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M3 2.25h18s1.5 0 1.5 1.5v16.5s0 1.5 -1.5 1.5H3s-1.5 0 -1.5 -1.5V3.75s0 -1.5 1.5 -1.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m1.5 6.75 21 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m9 6.75 0 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m9 14.25 13.5 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 어나운스 바 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'announcement'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="-0.75 -0.75 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M6.328125 14.296875H4.21875a3.515625 3.515625 0 0 1 0 -7.03125h2.109375Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M6.328125 14.296875a20.90625 20.90625 0 0 1 11.593125 3.5100000000000002l1.0631249999999999 0.70875V3.046875l-1.0631249999999999 0.70875A20.90625 20.90625 0 0 1 6.328125 7.265625Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="m21.796875 9.375 0 2.8125" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<path d="M6.328125 14.296875A6.7865625 6.7865625 0 0 0 8.4375 19.21875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 사이트 정보 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'site-info'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M324-111.5Q251-143 197-197t-85.5-127Q80-397 80-480t31.5-156Q143-709 197-763t127-85.5Q397-880 480-880t156 31.5Q709-817 763-763t85.5 127Q880-563 880-480t-31.5 156Q817-251 763-197t-127 85.5Q563-80 480-80t-156-31.5ZM440-162v-78q-33 0-56.5-23.5T360-320v-40L168-552q-3 18-5.5 36t-2.5 36q0 121 79.5 212T440-162Zm276-102q41-45 62.5-100.5T800-480q0-98-54.5-179T600-776v16q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h240q17 0 28.5 11.5T600-440v120h40q26 0 47 15.5t29 40.5Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- SNS 정보 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'social'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm508.5-291.5Q720-743 720-760t-11.5-28.5Q697-800 680-800t-28.5 11.5Q640-777 640-760t11.5 28.5Q663-720 680-720t28.5-11.5ZM680-200ZM200-480Zm480-280Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- POST 설정 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'post-settings'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M280-280h280v-80H280v80Zm0-160h400v-80H280v80Zm0-160h400v-80H280v80Zm-80 480q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 브랜드 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'brand'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="m260-520 220-360 220 360H260ZM700-80q-75 0-127.5-52.5T520-260q0-75 52.5-127.5T700-440q75 0 127.5 52.5T880-260q0 75-52.5 127.5T700-80Zm-580-20v-320h320v320H120Zm580-60q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Zm-500-20h160v-160H200v160Zm202-420h156l-78-126-78 126Zm78 0ZM360-340Zm340 80Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 사이트 코드 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'site-code'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M80-680v-80q0-33 23.5-56.5T160-840h640q33 0 56.5 23.5T880-760v80h-80v-80H160v80H80Zm240 560v-80H160q-33 0-56.5-23.5T80-280v-80h80v80h640v-80h80v80q0 33-23.5 56.5T800-200H640v80H320Zm160-400Zm-288 0 104-104-56-56L80-520l160 160 56-56-104-104Zm576 0L664-416l56 56 160-160-160-160-56 56 104 104Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Ads -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'ads'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M468-240q-96-5-162-74t-66-166q0-100 70-170t170-70q97 0 166 66t74 162l-84-25q-13-54-56-88.5T480-640q-66 0-113 47t-47 113q0 57 34.5 100t88.5 56l25 84Zm48 158q-9 2-18 2h-18q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480v18q0 9-2 18l-78-24v-12q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93h12l24 78Zm305 22L650-231 600-80 480-480l400 120-151 50 171 171-79 79Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 게시물보내기 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'post-export'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 게시물 가져오기 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'post-import'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M440-200h80v-167l64 64 56-57-160-160-160 160 57 56 63-63v167ZM240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM240-800v200-200 640-640Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 스팸 필터 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="iconId === 'spam'"
|
||||||
|
class="admin-settings-nav-icon__svg pointer-events-none size-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M19.0902 4.90918L4.9082 19.0912" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="admin-settings-nav-icon__placeholder size-4 rounded-sm border border-dashed border-[#c8ced3]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
156
components/admin/AdminSiteCodeSettingsCard.vue
Normal file
156
components/admin/AdminSiteCodeSettingsCard.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 사이트 코드 설정 카드
|
||||||
|
* @property {Object} form - 사이트 설정 폼 객체
|
||||||
|
* @property {boolean} editing - 편집 모드 여부
|
||||||
|
* @property {boolean} saving - 저장 중 여부
|
||||||
|
* @property {boolean} hasChanges - 변경 여부
|
||||||
|
*/
|
||||||
|
defineProps({
|
||||||
|
form: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
editing: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
saving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
hasChanges: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['begin', 'cancel', 'save'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="admin-settings-section-site-code"
|
||||||
|
class="admin-site-code-settings-card admin-settings-screen__card admin-settings-screen__card--site-code relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
|
||||||
|
사이트 코드
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
v-if="!editing"
|
||||||
|
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
|
||||||
|
>
|
||||||
|
광고·검색엔진·외부 위젯 검증에 필요한 ads.txt와 공통 헤더·푸터 코드를 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
|
||||||
|
<template v-if="!editing">
|
||||||
|
<button
|
||||||
|
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('begin')"
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving || !hasChanges"
|
||||||
|
@click="$emit('save')"
|
||||||
|
>
|
||||||
|
{{ saving ? '저장 중' : '저장' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!editing"
|
||||||
|
class="admin-site-code-settings-card__readonly admin-settings-screen__site-code-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
|
||||||
|
>
|
||||||
|
<div class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[7.5rem_minmax(0,1fr)] md:items-center md:gap-5">
|
||||||
|
<p class="font-normal text-[#3f4650]">
|
||||||
|
ads.txt
|
||||||
|
</p>
|
||||||
|
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
|
||||||
|
{{ form.adsTxt.trim() ? '등록됨' : '미등록' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[7.5rem_minmax(0,1fr)] md:items-center md:gap-5">
|
||||||
|
<p class="font-normal text-[#3f4650]">
|
||||||
|
헤더 코드
|
||||||
|
</p>
|
||||||
|
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
|
||||||
|
{{ form.customHeadCode.trim() ? '등록됨' : '미등록' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[7.5rem_minmax(0,1fr)] md:items-center md:gap-5">
|
||||||
|
<p class="font-normal text-[#3f4650]">
|
||||||
|
푸터 코드
|
||||||
|
</p>
|
||||||
|
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
|
||||||
|
{{ form.customFooterCode.trim() ? '등록됨' : '미등록' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="admin-site-code-settings-card__edit admin-settings-screen__site-code-edit grid gap-5 border-t border-[#eceff2] pt-5"
|
||||||
|
>
|
||||||
|
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||||
|
<span class="font-medium text-[#3f4650]">ads.txt</span>
|
||||||
|
<p class="text-xs leading-relaxed text-[#657080]">
|
||||||
|
루트 /ads.txt에서 text/plain으로 응답됩니다. 애드센스에서 제공한 줄을 그대로 붙여 넣습니다.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
v-model="form.adsTxt"
|
||||||
|
class="min-h-[7rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
rows="5"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||||
|
<span class="font-medium text-[#3f4650]">헤더 코드</span>
|
||||||
|
<p class="text-xs leading-relaxed text-[#657080]">
|
||||||
|
공개 페이지의 head 끝에 삽입됩니다. 애드센스 자동 광고, 사이트 검증 meta/script 코드에 사용합니다.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
v-model="form.customHeadCode"
|
||||||
|
class="min-h-[9rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
rows="7"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="헤더에 삽입할 meta 또는 script 코드를 붙여 넣습니다."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||||
|
<span class="font-medium text-[#3f4650]">푸터 코드</span>
|
||||||
|
<p class="text-xs leading-relaxed text-[#657080]">
|
||||||
|
공개 페이지의 body 끝에 삽입됩니다. 하단 추적 스크립트나 지연 로딩 코드에 사용합니다.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
v-model="form.customFooterCode"
|
||||||
|
class="min-h-[9rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
rows="7"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="푸터에 삽입할 script 코드를 붙여 넣습니다."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
135
components/admin/AdminSlashCommandIcon.vue
Normal file
135
components/admin/AdminSlashCommandIcon.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 슬래시 명령 메뉴 아이콘 (Ghost 스타일 라인 아이콘)
|
||||||
|
*/
|
||||||
|
const props = defineProps({
|
||||||
|
/** @type {import('vue').PropType<string>} */
|
||||||
|
commandId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="admin-slash-command-icon"
|
||||||
|
:class="`admin-slash-command-icon--${commandId}`"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<!-- image -->
|
||||||
|
<template v-if="commandId === 'image'">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="m19.642 16.276-3.85-7a.517.517 0 0 0-.181-.189.585.585 0 0 0-.749.115l-4.533 5.494-2.307-2.516a.548.548 0 0 0-.206-.14.598.598 0 0 0-.499.031.529.529 0 0 0-.183.164l-2.75 4a.468.468 0 0 0-.015.507.526.526 0 0 0 .202.189c.084.045.18.069.28.069H19.15a.594.594 0 0 0 .268-.063.532.532 0 0 0 .2-.174.462.462 0 0 0 .024-.487ZM9.25 9c.911 0 1.65-.672 1.65-1.5S10.161 6 9.25 6c-.91 0-1.65.672-1.65 1.5S8.34 9 9.25 9Z"></path>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- gallery -->
|
||||||
|
<template v-else-if="commandId === 'gallery'">
|
||||||
|
<g clip-path="url(#a)"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 5H3.2C1.985 5 1 5.806 1 6.8v14.4c0 .994.985 1.8 2.2 1.8h17.6c1.215 0 2.2-.806 2.2-1.8V6.8c0-.994-.985-1.8-2.2-1.8ZM6 1h12"></path><path fill="currentColor" d="M15.142 10.264a.75.75 0 0 1 .529.4l4 8A.75.75 0 0 1 19 19.75H6a.75.75 0 0 1-.498-1.31l9-8a.75.75 0 0 1 .64-.176ZM7 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"></path></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h24v24H0z"></path></clipPath></defs>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- h1 -->
|
||||||
|
<template v-else-if="commandId === 'h1'">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M17.926 6.578v10.898c0 .602.33.963.862.963.541 0 .862-.351.862-.963V5.726c0-.682-.451-1.153-1.093-1.153-.39 0-.742.15-1.373.622l-2.026 1.504c-.4.29-.591.561-.591.852 0 .38.3.692.672.692.22 0 .43-.08.721-.291l1.885-1.374zM4.42 4.903a.77.77 0 0 1 .77.77v5.35h6.168v-5.35a.77.77 0 1 1 1.54 0v12.242a.77.77 0 0 1-1.54 0v-5.351H5.19v5.351a.77.77 0 1 1-1.54 0V5.673a.77.77 0 0 1 .77-.77"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- h2 -->
|
||||||
|
<template v-else-if="commandId === 'h2'">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M14.159 16c-.562.638-.725.905-.725 1.248 0 .553.439.886 1.135.886h6.289c.524 0 .829-.276.829-.724 0-.457-.324-.734-.83-.734H15.57v-.114l3.526-4.031c1.715-1.953 2.201-2.859 2.201-4.098 0-2.096-1.648-3.583-3.993-3.583-2.515 0-4.088 1.697-4.088 3.317 0 .514.305.867.772.867.39 0 .658-.258.791-.763.286-1.238 1.191-1.972 2.42-1.972 1.449 0 2.411.896 2.411 2.24 0 .895-.41 1.695-1.486 2.925zM3.419 5.364c.404 0 .731.327.731.731v5.087h5.863V6.095a.732.732 0 1 1 1.464 0v11.637a.732.732 0 0 1-1.464 0v-5.087H4.15v5.087a.732.732 0 1 1-1.463 0V6.095c0-.404.327-.731.732-.731"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- h3 -->
|
||||||
|
<template v-else-if="commandId === 'h3'">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.05 5.856a.75.75 0 0 0-1.5 0v11.921a.75.75 0 1 0 1.5 0v-5.21h6.006v5.21a.75.75 0 0 0 1.5 0V5.856a.75.75 0 0 0-1.5 0v5.21H4.05zm9.479 9.234c-.418 0-.713.304-.713.732 0 1.454 1.796 2.936 4.248 2.936 2.642 0 4.486-1.558 4.486-3.782 0-1.635-1.226-3.041-2.832-3.222v-.095c1.321-.228 2.395-1.596 2.395-3.031 0-1.977-1.692-3.393-4.068-3.393-2.338 0-3.925 1.425-3.925 2.898 0 .476.285.79.723.79.37 0 .608-.2.798-.723.38-.979 1.235-1.54 2.366-1.54 1.454 0 2.433.875 2.433 2.177s-1.007 2.242-2.395 2.242h-1.121c-.456 0-.76.295-.76.713 0 .409.323.723.76.723h1.188c1.654 0 2.765.978 2.765 2.432s-1.083 2.386-2.784 2.386c-1.293 0-2.281-.57-2.775-1.587-.247-.485-.456-.656-.789-.656"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- h4 -->
|
||||||
|
<template v-else-if="commandId === 'h4'">
|
||||||
|
<path fill="currentColor" d="M140-290v-380h60v160h180v-160h60v380h-60v-160H200v160h-60Zm580 0v-120H520v-260h60v200h140v-200h60v200h80v60h-80v120h-60Z"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- quote -->
|
||||||
|
<template v-else-if="commandId === 'quote'">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5 10.966v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024H6.704A3.35 3.35 0 0 1 9.845 7.75v-1.5A4.845 4.845 0 0 0 5 10.966m8 0v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024h-3.415a3.35 3.35 0 0 1 3.141-2.192v-1.5A4.845 4.845 0 0 0 13 10.966"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- list -->
|
||||||
|
<template v-else-if="commandId === 'list'">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.3 7.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2m4.033-1.75a.75.75 0 0 0 0 1.5l11.917.001a.75.75 0 0 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 1 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 0 0 0-1.5zM5.3 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1 6.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- code -->
|
||||||
|
<template v-else-if="commandId === 'code'">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M2.25 6A2.75 2.75 0 0 1 5 3.25h14A2.75 2.75 0 0 1 21.75 6v12A2.75 2.75 0 0 1 19 20.75H5A2.75 2.75 0 0 1 2.25 18zM5 4.75c-.69 0-1.25.56-1.25 1.25v12c0 .69.56 1.25 1.25 1.25h14c.69 0 1.25-.56 1.25-1.25V6c0-.69-.56-1.25-1.25-1.25zm5.53 4.62a.75.75 0 0 1 0 1.06l-1.59 1.591 1.59 1.591a.75.75 0 0 1-1.06 1.06l-2.122-2.12a.75.75 0 0 1 0-1.061L9.47 9.37a.75.75 0 0 1 1.06 0m2.94 1.06a.75.75 0 1 1 1.06-1.06l2.122 2.12a.75.75 0 0 1 0 1.062l-2.122 2.12a.75.75 0 1 1-1.06-1.06l1.59-1.59z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- divider -->
|
||||||
|
<template v-else-if="commandId === 'divider'">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 4.25a.75.75 0 0 1 .75.75v1c0 .69.56 1.25 1.25 1.25h12c.69 0 1.25-.56 1.25-1.25V5a.75.75 0 0 1 1.5 0v1A2.75 2.75 0 0 1 18 8.75H6A2.75 2.75 0 0 1 3.25 6V5A.75.75 0 0 1 4 4.25m0 15.5a.75.75 0 0 0 .75-.75v-1c0-.69.56-1.25 1.25-1.25h12c.69 0 1.25.56 1.25 1.25v1a.75.75 0 0 0 1.5 0v-1A2.75 2.75 0 0 0 18 15.25H6A2.75 2.75 0 0 0 3.25 18v1c0 .414.336.75.75.75m-1-8.5a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5H3m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5H7.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5h-1.2m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5h-1.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5H21a.75.75 0 0 0 0-1.5h-1.2"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- callout -->
|
||||||
|
<template v-else-if="commandId === 'callout'">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="M12 18a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.5v6"></path>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- toggle -->
|
||||||
|
<template v-else-if="commandId === 'toggle'">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 11 12 15l-4.5-4"></path>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- embed -->
|
||||||
|
<template v-else-if="commandId === 'embed'">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm.5 16c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5V9.8l4.7-5.3H19c.3 0 .5.2.5.5v14zm-6-9.5L16 12l-2.5 2.8 1.1 1L18 12l-3.5-3.5-1 1zm-3 0l-1-1L6 12l3.5 3.8 1.1-1L8 12l2.5-2.5z"></path>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- fallback -->
|
||||||
|
<template v-else>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12 4.5c.46 0 .833.373.833.833v5.834h5.834a.833.833 0 0 1 0 1.666h-5.834v5.834a.833.833 0 0 1-1.666 0v-5.834H5.333a.833.833 0 0 1 0-1.666h5.834V5.333c0-.46.373-.833.833-.833"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
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>
|
||||||
547
components/comments/PostComments.vue
Normal file
547
components/comments/PostComments.vue
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
<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([])
|
||||||
|
const canSubmitComment = computed(() => Boolean(newCommentBody.value.trim()) && !submitting.value)
|
||||||
|
const canSubmitReply = computed(() => Boolean(replyBody.value.trim()) && !submittingReplyId.value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 댓글 시간을 상대 시간 형식으로 변환한다.
|
||||||
|
* @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">정렬:</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">인기순</option>
|
||||||
|
<option value="latest">최신순</option>
|
||||||
|
<option value="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:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="!canSubmitComment"
|
||||||
|
@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] 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:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="!canSubmitReply"
|
||||||
|
@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>
|
||||||
128
components/content/ContentMarkdownCalloutEditor.vue
Normal file
128
components/content/ContentMarkdownCalloutEditor.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup>
|
||||||
|
import { buildCalloutOpenerLine } from '../../lib/markdown-callout.js'
|
||||||
|
import ProseCallout from './ProseCallout.vue'
|
||||||
|
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** 콜아웃 본문 */
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
calloutEmojiEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
calloutEmoji: {
|
||||||
|
type: String,
|
||||||
|
default: '💡'
|
||||||
|
},
|
||||||
|
calloutTitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
calloutBackground: {
|
||||||
|
type: String,
|
||||||
|
default: 'blue'
|
||||||
|
},
|
||||||
|
/** 본문 첫 줄 source-line(0-based) */
|
||||||
|
bodySourceLine: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
/** 콜아웃 선언 줄 source-line(0-based) */
|
||||||
|
blockSourceLine: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['commit', 'delete-line', 'insert-above', 'insert-below', 'merge-with-previous', 'leave-block'])
|
||||||
|
|
||||||
|
const bodyLines = computed(() => {
|
||||||
|
const lines = String(props.modelValue ?? '').replace(/\r/g, '').split('\n')
|
||||||
|
return lines.length ? lines : ['']
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 마크다운 줄을 반영한다.
|
||||||
|
* @param {string[]} contentLines - 본문 줄
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const commitCalloutLines = (contentLines) => {
|
||||||
|
emit('commit', [
|
||||||
|
buildCalloutOpenerLine({
|
||||||
|
calloutEmojiEnabled: props.calloutEmojiEnabled,
|
||||||
|
calloutEmoji: props.calloutEmoji,
|
||||||
|
calloutBackground: props.calloutBackground,
|
||||||
|
title: props.calloutTitle
|
||||||
|
}),
|
||||||
|
...contentLines,
|
||||||
|
':::'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 본문 문자열을 줄 목록으로 정규화한다.
|
||||||
|
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||||
|
* @returns {string[]} 본문 줄
|
||||||
|
*/
|
||||||
|
const normalizeBodyLines = (payload) => {
|
||||||
|
const value = typeof payload === 'string'
|
||||||
|
? payload
|
||||||
|
: String(payload?.value ?? '')
|
||||||
|
|
||||||
|
const lines = String(value ?? '').replace(/\r/g, '').split('\n')
|
||||||
|
return lines.length ? lines : ['']
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문 편집 반영
|
||||||
|
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBodyCommit = (payload) => {
|
||||||
|
commitCalloutLines(normalizeBodyLines(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문 입력 중 마크다운을 동기화한다.
|
||||||
|
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBodyInput = (payload) => {
|
||||||
|
commitCalloutLines(normalizeBodyLines(payload))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="content-markdown-callout-editor relative"
|
||||||
|
:data-source-line="blockSourceLine"
|
||||||
|
>
|
||||||
|
<ProseCallout
|
||||||
|
:emoji-enabled="calloutEmojiEnabled"
|
||||||
|
:emoji="calloutEmoji"
|
||||||
|
:background="calloutBackground"
|
||||||
|
:title="calloutTitle"
|
||||||
|
>
|
||||||
|
<ContentMarkdownEditableInline
|
||||||
|
block-class="content-markdown-callout-editor__body min-w-0 text-[15px] leading-8 text-[var(--site-text)]"
|
||||||
|
enter-mode="multiline"
|
||||||
|
plain-text
|
||||||
|
arrow-exit-creates-line
|
||||||
|
preserve-empty-line-on-full-delete
|
||||||
|
:source-line="bodySourceLine"
|
||||||
|
:source-line-count="bodyLines.length"
|
||||||
|
:model-value="modelValue"
|
||||||
|
@input="onBodyInput"
|
||||||
|
@commit="onBodyCommit"
|
||||||
|
@delete-line="emit('delete-line', $event)"
|
||||||
|
@insert-above="emit('insert-above', $event)"
|
||||||
|
@insert-below="emit('insert-below', $event)"
|
||||||
|
@merge-with-previous="emit('merge-with-previous', bodySourceLine, $event)"
|
||||||
|
@leave-block="emit('leave-block', $event)"
|
||||||
|
/>
|
||||||
|
</ProseCallout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
190
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
190
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script setup>
|
||||||
|
import { buildCodeBlockLines } from '../../lib/markdown-code-block.js'
|
||||||
|
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||||
|
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** 코드 본문 */
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 언어(slug) */
|
||||||
|
language: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 줄 번호 표시 */
|
||||||
|
showLineNumbers: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
/** 본문 첫 줄 source-line(0-based) */
|
||||||
|
bodySourceLine: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['commit', 'insert-above', 'insert-below', 'delete-line'])
|
||||||
|
|
||||||
|
const languageDraft = ref(props.language)
|
||||||
|
const lineNumbersEnabled = ref(props.showLineNumbers)
|
||||||
|
const liveBody = ref(props.modelValue)
|
||||||
|
|
||||||
|
watch(() => props.language, (value) => {
|
||||||
|
languageDraft.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.showLineNumbers, (value) => {
|
||||||
|
lineNumbersEnabled.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (value) => {
|
||||||
|
liveBody.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @type {import('vue').ComputedRef<string[]>} */
|
||||||
|
const bodyLines = computed(() => {
|
||||||
|
const text = String(liveBody.value ?? '')
|
||||||
|
|
||||||
|
if (!text.length) {
|
||||||
|
return ['']
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.split('\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @type {import('vue').ComputedRef<number[]>} */
|
||||||
|
const gutterLines = computed(() => bodyLines.value.map((_, index) => index + 1))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마크다운에 코드 블록을 반영한다.
|
||||||
|
* @param {string} body - 본문
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const commitCodeBlock = (body) => {
|
||||||
|
emit('commit', buildCodeBlockLines({
|
||||||
|
language: languageDraft.value,
|
||||||
|
showLineNumbers: lineNumbersEnabled.value,
|
||||||
|
body
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문 편집 반영
|
||||||
|
* @param {string} body - 본문
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBodyCommit = (body) => {
|
||||||
|
liveBody.value = body
|
||||||
|
commitCodeBlock(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력 중 줄 번호 갱신용 본문 동기화
|
||||||
|
* @param {string} body - 본문
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBodyInput = (body) => {
|
||||||
|
liveBody.value = body
|
||||||
|
commitCodeBlock(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 블록 아래로 이탈(다음 문단 생성)
|
||||||
|
* @param {Object} payload - insert-below 페이로드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onExitBelow = (payload) => {
|
||||||
|
emit('insert-below', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 언어 입력 반영
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onLanguageCommit = () => {
|
||||||
|
commitCodeBlock(props.modelValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줄 번호 표시를 토글한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleLineNumbers = () => {
|
||||||
|
lineNumbersEnabled.value = !lineNumbersEnabled.value
|
||||||
|
commitCodeBlock(props.modelValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ProseCodeBlock
|
||||||
|
class="content-markdown-code-block-editor"
|
||||||
|
:show-line-numbers="lineNumbersEnabled"
|
||||||
|
:line-numbers="gutterLines"
|
||||||
|
>
|
||||||
|
<template #header-tools>
|
||||||
|
<div
|
||||||
|
class="content-markdown-code-block-editor__toolbar pointer-events-none flex items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="content-markdown-code-block-editor__line-numbers pointer-events-auto rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/15 hover:text-white"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="lineNumbersEnabled"
|
||||||
|
:title="lineNumbersEnabled ? '줄 번호 숨기기' : '줄 번호 표시'"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="toggleLineNumbers"
|
||||||
|
>
|
||||||
|
{{ lineNumbersEnabled ? '줄번호' : '줄번호 끔' }}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-model="languageDraft"
|
||||||
|
class="content-markdown-code-block-editor__language pointer-events-auto w-[7.5rem] rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs text-white outline-none transition-colors placeholder:text-white/35 focus:border-white/30 focus:bg-white/15"
|
||||||
|
type="text"
|
||||||
|
placeholder="Language..."
|
||||||
|
spellcheck="false"
|
||||||
|
@mousedown.stop
|
||||||
|
@keydown.stop
|
||||||
|
@blur="onLanguageCommit"
|
||||||
|
@keydown.enter.prevent="onLanguageCommit"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ContentMarkdownEditableInline
|
||||||
|
tag="pre"
|
||||||
|
block-class="content-markdown-code-block-editor__editor m-0 min-w-0 border-0 bg-transparent p-0 font-mono text-sm leading-6 text-white outline-none"
|
||||||
|
enter-mode="multiline"
|
||||||
|
plain-text
|
||||||
|
arrow-exit-creates-line
|
||||||
|
:source-line="bodySourceLine"
|
||||||
|
:source-line-count="bodyLines.length"
|
||||||
|
:model-value="modelValue"
|
||||||
|
@input="onBodyInput"
|
||||||
|
@commit="onBodyCommit"
|
||||||
|
@insert-above="emit('insert-above', $event)"
|
||||||
|
@insert-below="onExitBelow"
|
||||||
|
@delete-line="emit('delete-line', $event)"
|
||||||
|
/>
|
||||||
|
</ProseCodeBlock>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.content-markdown-code-block-editor :deep(.prose-code-block__content) {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-markdown-code-block-editor :deep(.prose-code-block__header) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-markdown-code-block-editor__toolbar {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-markdown-code-block-editor__toolbar > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1521
components/content/ContentMarkdownEditableInline.vue
Normal file
1521
components/content/ContentMarkdownEditableInline.vue
Normal file
File diff suppressed because it is too large
Load Diff
3630
components/content/ContentMarkdownRenderer.vue
Normal file
3630
components/content/ContentMarkdownRenderer.vue
Normal file
File diff suppressed because it is too large
Load Diff
164
components/content/ContentMarkdownToggleEditor.vue
Normal file
164
components/content/ContentMarkdownToggleEditor.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup>
|
||||||
|
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
|
||||||
|
import ProseToggle from './ProseToggle.vue'
|
||||||
|
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** 토글 제목 */
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 토글 본문 */
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 기본 펼침 여부 */
|
||||||
|
defaultOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/** 선언 줄 source-line(0-based) */
|
||||||
|
titleSourceLine: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
/** 본문 첫 줄 source-line(0-based) */
|
||||||
|
bodySourceLine: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['commit', 'insert-above', 'insert-below', 'delete-line'])
|
||||||
|
|
||||||
|
const titleEditorRef = ref(null)
|
||||||
|
const bodyEditorRef = ref(null)
|
||||||
|
const titleDraft = ref(props.title)
|
||||||
|
|
||||||
|
watch(() => props.title, (value) => {
|
||||||
|
titleDraft.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토글 마크다운을 반영한다.
|
||||||
|
* @param {{ title?: string, body?: string }} options - 옵션
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const commitToggle = (options = {}) => {
|
||||||
|
emit('commit', buildToggleBlockLines({
|
||||||
|
title: options.title ?? titleDraft.value,
|
||||||
|
body: options.body ?? props.modelValue,
|
||||||
|
defaultOpen: options.defaultOpen ?? props.defaultOpen
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목 편집 반영
|
||||||
|
* @param {string} value - 제목
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onTitleCommit = (value) => {
|
||||||
|
titleDraft.value = String(value ?? '').trim()
|
||||||
|
commitToggle({ title: titleDraft.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목 필드 이탈 전 로컬 초안 동기화(한글 조합·↓ 이동 시 본문 오염 방지)
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const syncTitleDraft = () => {
|
||||||
|
titleDraft.value = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목 Enter — 본문으로 포커스 이동
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onTitleEnterAdvance = () => {
|
||||||
|
const nextTitle = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
|
||||||
|
titleDraft.value = nextTitle
|
||||||
|
commitToggle({ title: titleDraft.value })
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
bodyEditorRef.value?.focusEditor('start')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문 편집 반영
|
||||||
|
* @param {string} body - 본문
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBodyCommit = (body) => {
|
||||||
|
commitToggle({ body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토글 아래로 이탈
|
||||||
|
* @param {Object} payload - insert-below 페이로드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onExitBelow = (payload) => {
|
||||||
|
emit('insert-below', payload)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ProseToggle
|
||||||
|
class="content-markdown-toggle-editor"
|
||||||
|
data-editable-scope
|
||||||
|
:title="titleDraft"
|
||||||
|
:default-open="true"
|
||||||
|
animated
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<ContentMarkdownEditableInline
|
||||||
|
ref="titleEditorRef"
|
||||||
|
tag="div"
|
||||||
|
block-class="content-markdown-toggle-editor__title min-h-[1.75rem] outline-none"
|
||||||
|
enter-mode="focus-next"
|
||||||
|
navigation-scope="parent"
|
||||||
|
:source-line="titleSourceLine"
|
||||||
|
:model-value="titleDraft"
|
||||||
|
@mousedown.stop
|
||||||
|
@click.stop
|
||||||
|
@commit="onTitleCommit"
|
||||||
|
@enter-advance="onTitleEnterAdvance"
|
||||||
|
@leave-block="syncTitleDraft"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ContentMarkdownEditableInline
|
||||||
|
ref="bodyEditorRef"
|
||||||
|
tag="div"
|
||||||
|
block-class="content-markdown-toggle-editor__body min-h-[3rem] outline-none"
|
||||||
|
enter-mode="multiline"
|
||||||
|
navigation-scope="parent"
|
||||||
|
plain-text
|
||||||
|
arrow-exit-creates-line
|
||||||
|
:source-line="bodySourceLine"
|
||||||
|
:source-line-count="String(modelValue ?? '').split('\n').length"
|
||||||
|
:model-value="modelValue"
|
||||||
|
@commit="onBodyCommit"
|
||||||
|
@insert-above="emit('insert-above', $event)"
|
||||||
|
@insert-below="onExitBelow"
|
||||||
|
@delete-line="emit('delete-line', $event)"
|
||||||
|
/>
|
||||||
|
</ProseToggle>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.content-markdown-toggle-editor__title:empty::before {
|
||||||
|
content: '토글 제목';
|
||||||
|
color: var(--site-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-markdown-toggle-editor__body:empty::before {
|
||||||
|
content: '펼쳤을 때 보일 내용을 입력하세요';
|
||||||
|
color: var(--site-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
60
components/content/ProseAudio.vue
Normal file
60
components/content/ProseAudio.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시용 오디오 제목을 반환한다.
|
||||||
|
* @returns {string} 오디오 제목
|
||||||
|
*/
|
||||||
|
const displayTitle = computed(() => props.title || 'Audio')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재생 가능한 오디오 URL인지 확인한다.
|
||||||
|
* @returns {boolean} 오디오 URL 여부
|
||||||
|
*/
|
||||||
|
const hasAudioSource = computed(() => Boolean(props.src && (props.src.startsWith('/') || /^https?:\/\//i.test(props.src))))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-4 shadow-[0_14px_36px_rgba(15,23,42,0.06)] sm:p-5">
|
||||||
|
<div class="prose-audio__inner flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div class="prose-audio__icon flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[var(--site-accent)] text-white sm:h-[86px] sm:w-[86px]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-9 w-9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 18V5l10-2v13" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<circle cx="16" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="prose-audio__body min-w-0 flex-1">
|
||||||
|
<p class="prose-audio__title mb-2 text-base font-semibold leading-snug text-[var(--site-text)] sm:text-lg">
|
||||||
|
{{ displayTitle }}
|
||||||
|
</p>
|
||||||
|
<p v-if="description" class="prose-audio__description mb-3 text-sm leading-relaxed text-[var(--site-muted)]">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
<audio
|
||||||
|
v-if="hasAudioSource"
|
||||||
|
class="prose-audio__player w-full accent-[var(--site-accent)]"
|
||||||
|
:src="src"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<p v-else class="prose-audio__empty text-sm font-semibold text-[var(--site-muted)]">
|
||||||
|
오디오 URL이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
79
components/content/ProseBlockquote.vue
Normal file
79
components/content/ProseBlockquote.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
type: String,
|
||||||
|
default: 'gray'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const backgroundClass = computed(() => {
|
||||||
|
if (props.background === 'gray') {
|
||||||
|
return 'prose-blockquote--gray'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'blue') {
|
||||||
|
return 'prose-blockquote--blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'green') {
|
||||||
|
return 'prose-blockquote--green'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'yellow') {
|
||||||
|
return 'prose-blockquote--yellow'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'red') {
|
||||||
|
return 'prose-blockquote--red'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'purple') {
|
||||||
|
return 'prose-blockquote--purple'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'prose-blockquote--gray'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<blockquote
|
||||||
|
class="prose-blockquote mb-5 text-[15px] leading-8"
|
||||||
|
:class="variant === 'alt'
|
||||||
|
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic text-[var(--site-text)]'
|
||||||
|
: ['border-l-[3px] bg-transparent py-1 pl-5 pr-0 font-normal text-[#15171a]', backgroundClass]"
|
||||||
|
>
|
||||||
|
<span class="block whitespace-pre-line">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</blockquote>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose-blockquote--gray {
|
||||||
|
border-color: #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-blockquote--blue {
|
||||||
|
border-color: #0055ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-blockquote--green {
|
||||||
|
border-color: #16ae68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-blockquote--yellow {
|
||||||
|
border-color: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-blockquote--red {
|
||||||
|
border-color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-blockquote--purple {
|
||||||
|
border-color: #8800ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
93
components/content/ProseCallout.vue
Normal file
93
components/content/ProseCallout.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
emojiEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
type: String,
|
||||||
|
default: '💡'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
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 === 'blue') {
|
||||||
|
return 'prose-callout--blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.background === 'purple') {
|
||||||
|
return 'prose-callout--purple'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'prose-callout--red'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="prose-callout prose-callout-card mb-2.5 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
|
||||||
|
:class="backgroundClass"
|
||||||
|
>
|
||||||
|
<div v-if="emojiEnabled || title" class="prose-callout-card__header mb-2.5 flex items-start gap-2">
|
||||||
|
<span v-if="emojiEnabled" class="prose-callout-card__emoji inline-flex shrink-0 pt-0.5 text-[20px] leading-none">{{ emoji || '💡' }}</span>
|
||||||
|
<strong v-if="title" class="prose-callout-card__title min-w-0 text-[18px] leading-[1.35] font-bold text-[#050505]">
|
||||||
|
{{ title }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="prose-callout-card__body min-w-0 whitespace-pre-line">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose-callout--gray {
|
||||||
|
background: color-mix(in srgb, #050505 10%, #ffffff);
|
||||||
|
border: 1px solid #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--blue {
|
||||||
|
background: color-mix(in srgb, #0055ff 10%, #ffffff);
|
||||||
|
border: 1px solid #0055ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--green {
|
||||||
|
background: color-mix(in srgb, #16ae68 10%, #ffffff);
|
||||||
|
border: 1px solid #16ae68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--yellow {
|
||||||
|
background: color-mix(in srgb, #ffff00 10%, #ffffff);
|
||||||
|
border: 1px solid #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--red {
|
||||||
|
background: color-mix(in srgb, #ff0000 10%, #ffffff);
|
||||||
|
border: 1px solid #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-callout--purple {
|
||||||
|
background: color-mix(in srgb, #8800ff 10%, #ffffff);
|
||||||
|
border: 1px solid #8800ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
122
components/content/ProseCodeBlock.vue
Normal file
122
components/content/ProseCodeBlock.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
/** 언어 라벨(공개 화면 표시) */
|
||||||
|
language: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 줄 번호 표시 */
|
||||||
|
showLineNumbers: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/** 복사 버튼 표시(공개 화면) */
|
||||||
|
showCopy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/** 복사할 코드 본문 */
|
||||||
|
copyText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 줄 번호 목록 */
|
||||||
|
lineNumbers: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [1]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyDone = ref(false)
|
||||||
|
let copyDoneTimer = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 본문을 클립보드에 복사한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
const text = String(props.copyText ?? '')
|
||||||
|
|
||||||
|
if (!import.meta.client || !text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
copyDone.value = true
|
||||||
|
window.clearTimeout(copyDoneTimer)
|
||||||
|
copyDoneTimer = window.setTimeout(() => {
|
||||||
|
copyDone.value = false
|
||||||
|
}, 1600)
|
||||||
|
} catch {
|
||||||
|
copyDone.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(copyDoneTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="prose-code-block group relative mb-2.5 overflow-x-auto rounded bg-[#15171a] text-sm leading-6 text-white"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="$slots['header-tools'] || showCopy || language"
|
||||||
|
class="prose-code-block__header absolute right-3 top-2 z-10 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<slot name="header-tools" />
|
||||||
|
<span
|
||||||
|
v-if="language && !$slots['header-tools']"
|
||||||
|
class="prose-code-block__language text-xs text-white/50"
|
||||||
|
>{{ language }}</span>
|
||||||
|
<button
|
||||||
|
v-if="showCopy"
|
||||||
|
class="prose-code-block__copy rounded px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
type="button"
|
||||||
|
@click="copyToClipboard"
|
||||||
|
>
|
||||||
|
{{ copyDone ? '복사됨' : '복사' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-code-block__body flex">
|
||||||
|
<div
|
||||||
|
v-if="showLineNumbers"
|
||||||
|
class="prose-code-block__gutter shrink-0 select-none border-r border-white/10 py-3 pl-3 pr-2 font-mono text-xs leading-6 text-white/40 tabular-nums"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="lineNumber in lineNumbers"
|
||||||
|
:key="`prose-code-gutter-${lineNumber}`"
|
||||||
|
class="prose-code-block__gutter-line"
|
||||||
|
>
|
||||||
|
{{ lineNumber }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-code-block__content min-w-0 flex-1 px-4 py-3 font-mono text-sm leading-6">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose-code-block:focus-within {
|
||||||
|
outline: 2px solid rgb(255 255 255 / 0.22);
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-code-block__content :deep(code) {
|
||||||
|
display: block;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
246
components/content/ProseEmbed.vue
Normal file
246
components/content/ProseEmbed.vue
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<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 ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mastodon 공개 게시물 URL인지 확인하고 embed URL을 반환한다.
|
||||||
|
* @param {string} value - Mastodon 게시물 URL
|
||||||
|
* @returns {string} Mastodon embed URL
|
||||||
|
*/
|
||||||
|
const getMastodonEmbedUrl = (value) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(value.trim())
|
||||||
|
const path = parsedUrl.pathname.replace(/\/$/, '')
|
||||||
|
const isKnownNonMastodonHost = [
|
||||||
|
'twitter.com',
|
||||||
|
'x.com',
|
||||||
|
'mobile.twitter.com',
|
||||||
|
'youtube.com',
|
||||||
|
'www.youtube.com',
|
||||||
|
'youtu.be'
|
||||||
|
].includes(parsedUrl.hostname.replace(/^www\./, ''))
|
||||||
|
|
||||||
|
if (
|
||||||
|
isKnownNonMastodonHost ||
|
||||||
|
!['http:', 'https:'].includes(parsedUrl.protocol)
|
||||||
|
) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/@[^/]+\/\d+$/.test(path) || /^\/users\/[^/]+\/statuses\/\d+$/.test(path)) {
|
||||||
|
return `${parsedUrl.origin}${path}/embed`
|
||||||
|
}
|
||||||
|
} 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))
|
||||||
|
const mastodonEmbedUrl = computed(() => getMastodonEmbedUrl(props.url))
|
||||||
|
const mastodonIframeRef = ref(null)
|
||||||
|
const mastodonEmbedHeight = ref(640)
|
||||||
|
const mastodonEmbedId = ref(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 링크로 열어도 되는 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`
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mastodon embed iframe에 실제 콘텐츠 높이 계산을 요청한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const requestMastodonEmbedHeight = () => {
|
||||||
|
if (!mastodonIframeRef.value?.contentWindow || !mastodonEmbedId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mastodonIframeRef.value.contentWindow.postMessage({
|
||||||
|
type: 'setHeight',
|
||||||
|
id: mastodonEmbedId.value
|
||||||
|
}, '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mastodon embed 높이 응답을 반영한다.
|
||||||
|
* @param {MessageEvent} event - iframe 메시지 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleMastodonEmbedMessage = (event) => {
|
||||||
|
const data = event.data || {}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!mastodonIframeRef.value ||
|
||||||
|
event.source !== mastodonIframeRef.value.contentWindow ||
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
data.type !== 'setHeight' ||
|
||||||
|
data.id !== mastodonEmbedId.value ||
|
||||||
|
typeof data.height !== 'number'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expectedOrigin = new URL(mastodonEmbedUrl.value).origin
|
||||||
|
|
||||||
|
if (event.origin !== expectedOrigin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mastodonEmbedHeight.value = Math.max(320, Math.ceil(data.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mastodonEmbedId.value = Math.floor(Math.random() * 1000000000) + 1
|
||||||
|
window.addEventListener('message', handleMastodonEmbedMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('message', handleMastodonEmbedMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(mastodonEmbedUrl, () => {
|
||||||
|
mastodonEmbedHeight.value = 640
|
||||||
|
requestMastodonEmbedHeight()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px]"
|
||||||
|
:class="tweetEmbedUrl ? 'mx-auto max-w-[550px]' : mastodonEmbedUrl ? 'mx-auto max-w-[560px] border border-[var(--site-line)] bg-[var(--site-panel)]' : '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 block min-h-[560px] w-full border-0 sm:min-h-[620px]"
|
||||||
|
:src="tweetEmbedUrl"
|
||||||
|
title="Embedded post"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<iframe
|
||||||
|
v-else-if="mastodonEmbedUrl"
|
||||||
|
:key="mastodonEmbedUrl"
|
||||||
|
ref="mastodonIframeRef"
|
||||||
|
class="prose-embed__mastodon block w-full border-0"
|
||||||
|
:src="mastodonEmbedUrl"
|
||||||
|
:height="mastodonEmbedHeight"
|
||||||
|
title="Embedded Mastodon post"
|
||||||
|
allow="fullscreen"
|
||||||
|
loading="lazy"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||||
|
scrolling="no"
|
||||||
|
@load="requestMastodonEmbedHeight"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
117
components/content/ProseFile.vue
Normal file
117
components/content/ProseFile.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
href: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다운로드 가능한 파일 URL인지 확인한다.
|
||||||
|
* @returns {boolean} 파일 URL 여부
|
||||||
|
*/
|
||||||
|
const isSafeFileUrl = computed(() => Boolean(props.href && (props.href.startsWith('/') || /^https?:\/\//i.test(props.href))))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 확장자를 제거한 표시명을 반환한다.
|
||||||
|
* @param {string} filename - 파일명
|
||||||
|
* @returns {string} 확장자를 제외한 이름
|
||||||
|
*/
|
||||||
|
const stripFileExtension = (filename) => String(filename || '').replace(/\.[^.]+$/, '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 제목을 반환한다.
|
||||||
|
* @returns {string} 제목
|
||||||
|
*/
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
if (props.fileName && (!props.title || props.title === stripFileExtension(props.fileName))) {
|
||||||
|
return props.fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.title || props.fileName || 'File'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시 파일명을 반환한다.
|
||||||
|
* @returns {string} 파일명
|
||||||
|
*/
|
||||||
|
const displayFileName = computed(() => {
|
||||||
|
if (props.fileName) {
|
||||||
|
return props.fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = props.href.startsWith('/') ? new URL(props.href, 'https://local.invalid') : new URL(props.href)
|
||||||
|
const lastSegment = parsedUrl.pathname.split('/').filter(Boolean).pop()
|
||||||
|
return lastSegment ? decodeURIComponent(lastSegment) : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 카드 보조 정보를 반환한다.
|
||||||
|
* @returns {string} 보조 정보
|
||||||
|
*/
|
||||||
|
const displayMeta = computed(() => {
|
||||||
|
const title = String(displayTitle.value || '').trim()
|
||||||
|
const fileName = String(displayFileName.value || '').trim()
|
||||||
|
|
||||||
|
if (title && fileName && title === fileName) {
|
||||||
|
return props.size || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileName) {
|
||||||
|
return props.size ? `${fileName} · ${props.size}` : fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.size || props.href
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a
|
||||||
|
v-if="isSafeFileUrl"
|
||||||
|
class="prose-file group my-8 flex items-center gap-4 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-4 no-underline shadow-[0_14px_36px_rgba(15,23,42,0.06)] transition-[background-color,box-shadow] hover:bg-[color-mix(in_srgb,var(--site-panel)_86%,var(--site-text)_14%)] sm:p-5"
|
||||||
|
:href="href"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<span class="prose-file__body min-w-0 flex-1">
|
||||||
|
<span class="prose-file__title block text-base font-semibold leading-snug text-[var(--site-text)] sm:text-lg">
|
||||||
|
{{ displayTitle }}
|
||||||
|
</span>
|
||||||
|
<span v-if="description" class="prose-file__description mt-1 block text-sm leading-relaxed text-[var(--site-muted)]">
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
|
<span v-if="displayMeta" class="prose-file__meta mt-3 block truncate text-sm font-semibold text-[var(--site-text)]">
|
||||||
|
{{ displayMeta }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="prose-file__download flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[color-mix(in_srgb,var(--site-line)_36%,var(--site-panel))] text-[var(--site-accent)] transition-transform group-hover:scale-[1.02] sm:h-[86px] sm:w-[86px]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 4v11" />
|
||||||
|
<path d="m8 11 4 4 4-4" />
|
||||||
|
<path d="M5 20h14" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<p v-else class="prose-file prose-file-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>
|
||||||
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>
|
||||||
33
components/content/ProseHeading.vue
Normal file
33
components/content/ProseHeading.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 2
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="tagName"
|
||||||
|
:id="id || undefined"
|
||||||
|
class="prose-heading mb-2.5 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||||
|
style="scroll-margin-top: calc(var(--site-top-chrome-height, 57px) + 24px)"
|
||||||
|
: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>
|
||||||
126
components/content/ProseImage.vue
Normal file
126
components/content/ProseImage.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup>
|
||||||
|
import { getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 이미지 아래 표시용 캡션 */
|
||||||
|
caption: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'regular'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadFailed = ref(false)
|
||||||
|
|
||||||
|
const hasRenderableSrc = computed(() => String(props.src || '').trim().length > 0)
|
||||||
|
|
||||||
|
const errorLabel = computed(() => {
|
||||||
|
const trimmed = String(props.src || '').trim()
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return '이미지 URL이 비어 있습니다'
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = getImageDefaultAltLabel(trimmed)
|
||||||
|
|
||||||
|
return filename ? `이미지를 불러올 수 없습니다 · ${filename}` : '이미지를 불러올 수 없습니다'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.src, () => {
|
||||||
|
loadFailed.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 로드 실패 시 placeholder를 표시한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onImageError = () => {
|
||||||
|
loadFailed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 로드 성공 시 오류 상태를 해제한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onImageLoad = () => {
|
||||||
|
loadFailed.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<figure
|
||||||
|
class="prose-image mb-2.5"
|
||||||
|
: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="prose-image__frame overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||||
|
:class="{
|
||||||
|
'prose-image__frame--empty': !hasRenderableSrc || loadFailed,
|
||||||
|
'prose-image__frame--broken': loadFailed
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="hasRenderableSrc && !loadFailed"
|
||||||
|
class="prose-image__media w-full object-cover"
|
||||||
|
:src="src"
|
||||||
|
:alt="alt"
|
||||||
|
@load="onImageLoad"
|
||||||
|
@error="onImageError"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="prose-image__placeholder flex min-h-[180px] flex-col items-center justify-center gap-2 px-4 py-6 text-center"
|
||||||
|
role="img"
|
||||||
|
:aria-label="errorLabel"
|
||||||
|
>
|
||||||
|
<span class="prose-image__placeholder-icon text-2xl text-[var(--site-muted)]" aria-hidden="true">!</span>
|
||||||
|
<p class="prose-image__placeholder-text max-w-full break-all text-sm text-[var(--site-muted)]">
|
||||||
|
{{ errorLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figcaption
|
||||||
|
v-if="caption"
|
||||||
|
class="prose-image__caption mt-1.5 text-center text-sm text-[var(--site-muted)]"
|
||||||
|
>
|
||||||
|
{{ caption }}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose-image__frame--empty,
|
||||||
|
.prose-image__frame--broken {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-image__frame:not(.prose-image__frame--empty):not(.prose-image__frame--broken) {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-image__placeholder-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 2.5rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px dashed var(--site-line);
|
||||||
|
background: color-mix(in srgb, var(--site-panel) 88%, #fff 12%);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
17
components/content/ProseList.vue
Normal file
17
components/content/ProseList.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
ordered: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="ordered ? 'ol' : 'ul'"
|
||||||
|
class="prose-list mb-2.5 list-none space-y-2 pl-0 text-[15px] leading-8 text-[var(--site-text)]"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
112
components/content/ProseToggle.vue
Normal file
112
components/content/ProseToggle.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
/** 접힌 상태 제목 */
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 초기 펼침 여부 */
|
||||||
|
defaultOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/** 본문 열림·닫힘 애니메이션 */
|
||||||
|
animated: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOpen = ref(props.defaultOpen)
|
||||||
|
|
||||||
|
watch(() => props.defaultOpen, (value) => {
|
||||||
|
isOpen.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토글 펼침 상태를 전환한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleOpen = () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5"
|
||||||
|
:class="{ 'prose-toggle--open': isOpen }"
|
||||||
|
>
|
||||||
|
<div class="prose-toggle__header flex items-start gap-2">
|
||||||
|
<button
|
||||||
|
class="prose-toggle__trigger mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[var(--site-muted)] transition-colors hover:bg-black/5 hover:text-[var(--site-text)]"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
aria-label="토글 펼치기·접기"
|
||||||
|
@click="toggleOpen"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="prose-toggle__chevron size-4 transition-transform duration-300 ease-out"
|
||||||
|
:class="{ 'prose-toggle__chevron--open': isOpen }"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 4l4 4-4 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="prose-toggle__title min-w-0 flex-1 text-[15px] font-semibold leading-7 text-[var(--site-text)]">
|
||||||
|
<slot name="title">
|
||||||
|
{{ title || '더 보기' }}
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="prose-toggle__body-shell"
|
||||||
|
:class="[
|
||||||
|
animated ? 'prose-toggle__body-shell--animated' : '',
|
||||||
|
isOpen ? 'prose-toggle__body-shell--open' : ''
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="prose-toggle__body-inner min-h-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose-toggle__chevron--open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-toggle__body-shell--animated {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.32s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-toggle__body-shell--animated.prose-toggle__body-shell--open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated) .prose-toggle__body-inner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated).prose-toggle__body-shell--open .prose-toggle__body-inner {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
components/content/ProseVideo.vue
Normal file
54
components/content/ProseVideo.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재생 가능한 비디오 URL인지 확인한다.
|
||||||
|
* @returns {boolean} 비디오 URL 여부
|
||||||
|
*/
|
||||||
|
const hasVideoSource = computed(() => Boolean(props.src && (props.src.startsWith('/') || /^https?:\/\//i.test(props.src))))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<figure class="prose-video my-8">
|
||||||
|
<div class="prose-video__shell overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] shadow-[0_16px_40px_rgba(15,23,42,0.08)]">
|
||||||
|
<video
|
||||||
|
v-if="hasVideoSource"
|
||||||
|
class="prose-video__media aspect-video w-full bg-black object-cover"
|
||||||
|
:src="src"
|
||||||
|
:poster="poster || undefined"
|
||||||
|
:title="title || 'Video'"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="prose-video__empty flex aspect-video w-full items-center justify-center bg-[color-mix(in_srgb,var(--site-line)_45%,var(--site-panel))] text-sm font-semibold text-[var(--site-muted)]"
|
||||||
|
>
|
||||||
|
비디오 URL이 없습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figcaption
|
||||||
|
v-if="caption || title"
|
||||||
|
class="prose-video__caption mt-3 text-center text-sm leading-relaxed text-[var(--site-muted)]"
|
||||||
|
>
|
||||||
|
{{ caption || title }}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</template>
|
||||||
109
components/site/HomeHero.vue
Normal file
109
components/site/HomeHero.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
/** 커버 이미지 URL */
|
||||||
|
imageUrl: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 다크모드 커버 이미지 URL */
|
||||||
|
darkImageUrl: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 오버레이 제목 */
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 오버레이 본문 */
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @type {import('vue').ComputedRef<boolean>} */
|
||||||
|
const hasOverlay = computed(() => Boolean(props.title?.trim() || props.text?.trim()))
|
||||||
|
/** @type {import('vue').ComputedRef<boolean>} */
|
||||||
|
const lightImageUrl = computed(() => props.imageUrl?.trim() || props.darkImageUrl?.trim() || '')
|
||||||
|
/** @type {import('vue').ComputedRef<boolean>} */
|
||||||
|
const hasDarkImage = computed(() => Boolean(props.imageUrl?.trim() && props.darkImageUrl?.trim()))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="lightImageUrl"
|
||||||
|
class="home-hero relative mx-auto w-full max-w-[720px] overflow-hidden"
|
||||||
|
data-home-hero
|
||||||
|
>
|
||||||
|
<div class="home-hero__frame relative aspect-[720/215] w-full bg-[var(--site-panel)]">
|
||||||
|
<img
|
||||||
|
:class="[
|
||||||
|
'home-hero__cover home-hero__cover--light absolute inset-0 h-full w-full object-cover',
|
||||||
|
hasDarkImage ? '' : 'home-hero__cover--single'
|
||||||
|
]"
|
||||||
|
:src="lightImageUrl"
|
||||||
|
alt=""
|
||||||
|
loading="eager"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="hasDarkImage"
|
||||||
|
class="home-hero__cover home-hero__cover--dark absolute inset-0 h-full w-full object-cover"
|
||||||
|
:src="darkImageUrl"
|
||||||
|
alt=""
|
||||||
|
loading="eager"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="hasOverlay"
|
||||||
|
class="home-hero__overlay pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent px-4 pb-4 pt-12 sm:px-5 sm:pb-5"
|
||||||
|
>
|
||||||
|
<div class="home-hero__overlay-inner flex flex-col items-start gap-1 text-left">
|
||||||
|
<h2
|
||||||
|
v-if="title"
|
||||||
|
class="home-hero__title text-base font-semibold leading-snug text-white sm:text-lg"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
v-if="text"
|
||||||
|
class="home-hero__text max-w-[32rem] whitespace-pre-line text-sm leading-relaxed text-white/85"
|
||||||
|
>
|
||||||
|
{{ text }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-hero__cover--dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.home-hero__cover--light:not(.home-hero__cover--single) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero__cover--dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='light'] .home-hero__cover--light {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='light'] .home-hero__cover--dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] .home-hero__cover--light:not(.home-hero__cover--single) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] .home-hero__cover--dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
239
components/site/LeftSidebar.vue
Normal file
239
components/site/LeftSidebar.vue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
menuOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isDarkMode, toggleTheme } = useThemeMode()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||||
|
default: () => ({
|
||||||
|
adPostSidebarCode: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: tags } = await useFetch('/api/tags', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: navigation } = await useFetch('/api/navigation', {
|
||||||
|
default: () => ({
|
||||||
|
primary: [],
|
||||||
|
footer: [],
|
||||||
|
recommended: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 저자 영역 공개 여부 */
|
||||||
|
const showAuthorSection = false
|
||||||
|
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||||
|
|
||||||
|
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 v-if="showAuthorSection" 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>
|
||||||
|
|
||||||
|
<SiteAdSlot
|
||||||
|
v-if="isPostDetailRoute"
|
||||||
|
class="left-sidebar__post-ad-slot site-sidebar-section px-5 py-5 pr-3 max-lg:hidden xl:pl-0"
|
||||||
|
:code="siteSettings?.adPostSidebarCode"
|
||||||
|
location="post-sidebar-left"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
38
components/site/PostCard.vue
Normal file
38
components/site/PostCard.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
post: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="post-card site-section site-panel-hover group">
|
||||||
|
<div class="post-card__body site-section-body flex gap-4">
|
||||||
|
<PostCardMedia
|
||||||
|
:to="post.to"
|
||||||
|
:title="post.title"
|
||||||
|
:featured-image="post.featuredImage"
|
||||||
|
link-class="h-20 w-36 shrink-0"
|
||||||
|
aspect-class="h-full w-full"
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
v-if="post.excerpt"
|
||||||
|
class="post-card__excerpt post-summary-clamp post-summary-clamp--two 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>
|
||||||
62
components/site/PostCardMedia.vue
Normal file
62
components/site/PostCardMedia.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
/** 게시물 링크 */
|
||||||
|
to: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
/** 게시물 제목 */
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
/** 대표 이미지 URL */
|
||||||
|
featuredImage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 썸네일 비율·크기 Tailwind 클래스 */
|
||||||
|
aspectClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'aspect-square sm:aspect-video'
|
||||||
|
},
|
||||||
|
/** 링크 래퍼 추가 클래스 */
|
||||||
|
linkClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 이미지 추가 클래스 */
|
||||||
|
imageClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
:to="to"
|
||||||
|
class="post-card-media relative block"
|
||||||
|
:class="linkClass"
|
||||||
|
data-post-card-media
|
||||||
|
>
|
||||||
|
<figure class="post-card-media__figure overflow-hidden rounded-[10px]">
|
||||||
|
<img
|
||||||
|
v-if="featuredImage"
|
||||||
|
class="post-card-media__image w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
|
||||||
|
:class="[aspectClass, imageClass]"
|
||||||
|
:src="featuredImage"
|
||||||
|
:alt="title"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="post-card-media__placeholder flex w-full items-center justify-center rounded-[inherit] bg-[#F7F4EF] p-4 text-center text-xs leading-snug text-[var(--site-muted)] transition-opacity duration-200 group-hover:opacity-90"
|
||||||
|
:class="aspectClass"
|
||||||
|
:aria-label="title"
|
||||||
|
>
|
||||||
|
<span class="post-card-media__placeholder-text line-clamp-4">{{ title }}</span>
|
||||||
|
</span>
|
||||||
|
</figure>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
514
components/site/RightSidebar.vue
Normal file
514
components/site/RightSidebar.vue
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
<script setup>
|
||||||
|
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
|
||||||
|
import { getVisibleSocialLinks } from '~/lib/social-links.js'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const postToc = useState('post-detail-toc', () => [])
|
||||||
|
const tocNavRef = ref(null)
|
||||||
|
const activeTocId = ref('')
|
||||||
|
let tocScrollFrame = 0
|
||||||
|
|
||||||
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||||
|
default: () => ({
|
||||||
|
title: 'sori.studio',
|
||||||
|
description: 'sori.studio 개인 블로그',
|
||||||
|
logoText: '井',
|
||||||
|
logoUrl: '',
|
||||||
|
socialLinks: [],
|
||||||
|
copyrightText: '©2026 sori.studio'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: navigation } = await useFetch('/api/navigation', {
|
||||||
|
default: () => ({
|
||||||
|
primary: [],
|
||||||
|
footer: [],
|
||||||
|
recommended: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 추천 사이트 목록(비가시 제외)
|
||||||
|
* @returns {Array<{ id: string, label: string, url: string, descriptionText?: string, thumbnailUrl?: string }>}
|
||||||
|
*/
|
||||||
|
const recommendedSites = computed(() => {
|
||||||
|
const list = navigation.value?.recommended
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return list.filter((x) => x?.isVisible !== false)
|
||||||
|
})
|
||||||
|
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||||
|
const sidebarAdCode = computed(() => isPostDetailRoute.value ? '' : siteSettings.value?.adSidebarCode)
|
||||||
|
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
|
||||||
|
const followLinks = computed(() => getVisibleSocialLinks(siteSettings.value?.socialLinks || []))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고정 상단 영역을 고려한 TOC 판정 기준선을 반환한다.
|
||||||
|
* @returns {number} 뷰포트 상단 기준 오프셋
|
||||||
|
*/
|
||||||
|
const getTocActivationOffset = () => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return 96
|
||||||
|
}
|
||||||
|
|
||||||
|
const topChromeHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--site-top-chrome-height'))
|
||||||
|
|
||||||
|
return (Number.isFinite(topChromeHeight) ? topChromeHeight : 57) + 28
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 본문 스크롤 위치에 해당하는 TOC 항목 ID를 계산한다.
|
||||||
|
* @returns {string} 활성 제목 ID
|
||||||
|
*/
|
||||||
|
const findActiveTocId = () => {
|
||||||
|
if (!import.meta.client || !postTocItems.value.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = getTocActivationOffset()
|
||||||
|
const currentY = window.scrollY + offset
|
||||||
|
let activeId = postTocItems.value[0].id
|
||||||
|
|
||||||
|
for (const item of postTocItems.value) {
|
||||||
|
const target = document.getElementById(item.id)
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetY = target.getBoundingClientRect().top + window.scrollY
|
||||||
|
|
||||||
|
if (targetY <= currentY) {
|
||||||
|
activeId = item.id
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 TOC 링크가 내부 스크롤 영역 안에 보이도록 보정한다.
|
||||||
|
* @param {string} id - 활성 제목 ID
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const scrollActiveTocIntoView = (id) => {
|
||||||
|
if (!import.meta.client || !id || !(tocNavRef.value instanceof HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = tocNavRef.value
|
||||||
|
const link = nav.querySelector(`[data-toc-id="${id}"]`)
|
||||||
|
|
||||||
|
if (!(link instanceof HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const navTop = nav.scrollTop
|
||||||
|
const navBottom = navTop + nav.clientHeight
|
||||||
|
const linkTop = link.offsetTop
|
||||||
|
const linkBottom = linkTop + link.offsetHeight
|
||||||
|
const buffer = 24
|
||||||
|
|
||||||
|
if (linkTop < navTop + buffer) {
|
||||||
|
nav.scrollTo({
|
||||||
|
top: Math.max(0, linkTop - buffer),
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkBottom > navBottom - buffer) {
|
||||||
|
nav.scrollTo({
|
||||||
|
top: linkBottom - nav.clientHeight + buffer,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스크롤 이벤트에서 TOC 활성 항목을 갱신한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updateActiveToc = () => {
|
||||||
|
if (!import.meta.client || tocScrollFrame) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tocScrollFrame = window.requestAnimationFrame(() => {
|
||||||
|
tocScrollFrame = 0
|
||||||
|
const nextActiveId = findActiveTocId()
|
||||||
|
|
||||||
|
if (!nextActiveId || nextActiveId === activeTocId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTocId.value = nextActiveId
|
||||||
|
scrollActiveTocIntoView(nextActiveId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 탭으로 열 외부 URL인지
|
||||||
|
* @param {string} url - 링크
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
const isExternalNavUrl = (url) => /^https?:\/\//i.test(String(url || '').trim())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 사이트 보조 문구를 반환한다.
|
||||||
|
* @param {Object} item - 추천 사이트 항목
|
||||||
|
* @returns {string} 표시 문구
|
||||||
|
*/
|
||||||
|
const getRecommendedDisplayText = (item) => {
|
||||||
|
return String(item?.descriptionText || '').trim() || String(item?.url || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 사이트 이미지 URL을 반환한다.
|
||||||
|
* @param {Object} item - 추천 사이트 항목
|
||||||
|
* @returns {string} 이미지 URL
|
||||||
|
*/
|
||||||
|
const getRecommendedImageUrl = (item) => {
|
||||||
|
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
||||||
|
return thumbnailUrl || getExternalFaviconUrl(item?.url, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글 목차 링크를 부드럽게 이동한다.
|
||||||
|
* @param {MouseEvent} event - 클릭 이벤트
|
||||||
|
* @param {string} id - 이동할 제목 ID
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const scrollToTocItem = (event, id) => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = document.getElementById(id)
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
activeTocId.value = id
|
||||||
|
scrollActiveTocIntoView(id)
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
})
|
||||||
|
window.history.replaceState(null, '', `${route.path}#${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 소개 영역 공개 여부 */
|
||||||
|
const showAboutSection = false
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', updateActiveToc, { passive: true })
|
||||||
|
window.addEventListener('resize', updateActiveToc)
|
||||||
|
nextTick(updateActiveToc)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('scroll', updateActiveToc)
|
||||||
|
window.removeEventListener('resize', updateActiveToc)
|
||||||
|
|
||||||
|
if (tocScrollFrame) {
|
||||||
|
window.cancelAnimationFrame(tocScrollFrame)
|
||||||
|
tocScrollFrame = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([postTocItems, () => route.fullPath], async () => {
|
||||||
|
activeTocId.value = ''
|
||||||
|
await nextTick()
|
||||||
|
updateActiveToc()
|
||||||
|
})
|
||||||
|
</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-5 sm: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 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="followLinks.length" class="right-sidebar__block site-sidebar-section py-5 sm: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="right-sidebar__social-link site-interactive inline-flex h-5 w-5 items-center justify-center p-0.5 leading-none hover:opacity-75"
|
||||||
|
:href="item.href"
|
||||||
|
:aria-label="item.label"
|
||||||
|
:target="item.external ? '_blank' : undefined"
|
||||||
|
:rel="item.external ? 'noreferrer' : undefined"
|
||||||
|
>
|
||||||
|
<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="block h-4 w-4 shrink-0"
|
||||||
|
>
|
||||||
|
<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="block h-4 w-4 shrink-0"
|
||||||
|
>
|
||||||
|
<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="block h-4 w-4 shrink-0"
|
||||||
|
>
|
||||||
|
<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="block h-4 w-4 shrink-0"
|
||||||
|
>
|
||||||
|
<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="block h-4 w-4 shrink-0"
|
||||||
|
>
|
||||||
|
<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-if="item.icon === 'youtube'"
|
||||||
|
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="block h-4 w-4 shrink-0"
|
||||||
|
>
|
||||||
|
<path d="M2.5 17a24.1 24.1 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.6 49.6 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.1 24.1 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.6 49.6 0 0 1-16.2 0A2 2 0 0 1 2.5 17" />
|
||||||
|
<path d="m10 15 5-3-5-3z" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="item.icon === 'rss'"
|
||||||
|
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="block h-4 w-4 shrink-0"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
v-else-if="item.icon === 'custom' && item.iconSvg"
|
||||||
|
class="right-sidebar__custom-social-icon inline-flex h-4 w-4 items-center justify-center fill-[var(--site-text)]"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-html="item.iconSvg"
|
||||||
|
/>
|
||||||
|
<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="block h-4 w-4 shrink-0"
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.15-1.15" />
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">{{ item.label }}</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isPostDetailRoute"
|
||||||
|
class="right-sidebar__block right-sidebar__toc site-sidebar-section py-5 pl-5 pr-0 max-lg:hidden"
|
||||||
|
>
|
||||||
|
<div class="right-sidebar__row flex items-center justify-between">
|
||||||
|
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||||
|
TOC
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav ref="tocNavRef" class="right-sidebar__toc-nav mt-4 max-h-[min(28rem,calc(100vh-18rem))] overflow-y-auto pr-2" aria-label="게시글 목차">
|
||||||
|
<ul v-if="postTocItems.length" class="right-sidebar__toc-list list-none space-y-2 p-0">
|
||||||
|
<li v-for="item in postTocItems" :key="item.id">
|
||||||
|
<a
|
||||||
|
class="right-sidebar__toc-link site-interactive block rounded-md border-l-2 py-1.5 pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
|
||||||
|
:class="{
|
||||||
|
'border-[var(--site-accent)] bg-[var(--site-panel)] text-[var(--site-accent)] font-semibold': activeTocId === item.id,
|
||||||
|
'border-transparent text-[var(--site-text)]': activeTocId !== item.id,
|
||||||
|
'pl-2 font-semibold': item.level === 1,
|
||||||
|
'pl-5': item.level === 2,
|
||||||
|
'pl-8 text-xs': item.level === 3,
|
||||||
|
'site-muted': item.level === 3 && activeTocId !== item.id
|
||||||
|
}"
|
||||||
|
:href="`#${item.id}`"
|
||||||
|
:aria-current="activeTocId === item.id ? 'location' : undefined"
|
||||||
|
:data-toc-id="item.id"
|
||||||
|
@click="scrollToTocItem($event, item.id)"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="right-sidebar__toc-empty text-sm site-muted">
|
||||||
|
목차로 표시할 제목이 없습니다.
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="recommendedSites.length"
|
||||||
|
class="right-sidebar__block site-sidebar-section py-5 sm: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>
|
||||||
|
</div>
|
||||||
|
<ul class="right-sidebar__recommended-list mt-4 list-none flex flex-col gap-2.5 p-0">
|
||||||
|
<li v-for="item in recommendedSites" :key="item.id">
|
||||||
|
<a
|
||||||
|
class="right-sidebar__recommended-card site-interactive flex items-center gap-3 rounded-xl border border-[var(--site-line)] bg-[var(--site-panel)] px-3 py-2.5 transition-colors hover:border-[var(--site-accent)]"
|
||||||
|
:href="item.url"
|
||||||
|
:target="isExternalNavUrl(item.url) ? '_blank' : undefined"
|
||||||
|
:rel="isExternalNavUrl(item.url) ? 'nofollow noopener noreferrer' : undefined"
|
||||||
|
>
|
||||||
|
<span class="right-sidebar__recommended-icon grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-lg bg-[var(--site-paper)] text-xs font-bold text-[var(--site-text)] ring-1 ring-[var(--site-line)]">
|
||||||
|
<img
|
||||||
|
v-if="getRecommendedImageUrl(item)"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:src="getRecommendedImageUrl(item)"
|
||||||
|
width="36"
|
||||||
|
height="36"
|
||||||
|
:alt="item.thumbnailUrl ? item.label : ''"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
>
|
||||||
|
<span v-else class="px-1 text-center leading-none">{{ (item.label || '?').slice(0, 1) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-sm font-semibold text-[var(--site-text)]">{{ item.label }}</span>
|
||||||
|
<span class="mt-0.5 block truncate text-[11px] site-muted" :class="item.descriptionText ? '' : 'font-mono'">{{ getRecommendedDisplayText(item) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-xs site-muted" aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showAboutSection" 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>
|
||||||
|
|
||||||
|
<SiteAdSlot
|
||||||
|
class="right-sidebar__ad-slot site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0"
|
||||||
|
:code="sidebarAdCode"
|
||||||
|
location="sidebar"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.right-sidebar__custom-social-icon :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
width: 1rem !important;
|
||||||
|
height: 1rem !important;
|
||||||
|
max-width: 1rem;
|
||||||
|
max-height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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-[color:color-mix(in_srgb,var(--site-line)_88%,var(--site-panel)_12%)] 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 mt-1 pl-3 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>
|
||||||
79
components/site/SiteAdSlot.vue
Normal file
79
components/site/SiteAdSlot.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
code: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const slotRef = ref(null)
|
||||||
|
const mounted = ref(false)
|
||||||
|
const normalizedCode = computed(() => String(props.code || '').trim())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v-html로 삽입된 광고 스크립트를 브라우저에서 실행 가능한 노드로 교체한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const executeAdScripts = () => {
|
||||||
|
if (!import.meta.client || !(slotRef.value instanceof HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scripts = Array.from(slotRef.value.querySelectorAll('script'))
|
||||||
|
|
||||||
|
scripts.forEach((script) => {
|
||||||
|
const nextScript = document.createElement('script')
|
||||||
|
|
||||||
|
Array.from(script.attributes).forEach((attribute) => {
|
||||||
|
nextScript.setAttribute(attribute.name, attribute.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
nextScript.text = script.text || script.textContent || ''
|
||||||
|
script.replaceWith(nextScript)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(normalizedCode, async () => {
|
||||||
|
await nextTick()
|
||||||
|
executeAdScripts()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
mounted.value = true
|
||||||
|
await nextTick()
|
||||||
|
executeAdScripts()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="mounted && normalizedCode"
|
||||||
|
ref="slotRef"
|
||||||
|
class="site-ad-slot"
|
||||||
|
role="complementary"
|
||||||
|
aria-label="광고"
|
||||||
|
:data-ad-location="location || undefined"
|
||||||
|
v-html="normalizedCode"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.site-ad-slot {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-ad-slot :deep(ins.adsbygoogle) {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-ad-slot :deep(iframe) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
205
components/site/SiteAnnouncementBar.vue
Normal file
205
components/site/SiteAnnouncementBar.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ANNOUNCEMENT_SNOOZE_DAYS,
|
||||||
|
dismissAnnouncementForDays,
|
||||||
|
dismissAnnouncementForSession,
|
||||||
|
getAnnouncementBarTextColor,
|
||||||
|
isAnnouncementDismissed,
|
||||||
|
normalizeAnnouncementUrl
|
||||||
|
} from '~/lib/announcement-bar.js'
|
||||||
|
|
||||||
|
/** @type {number} 슬라이드 애니메이션 시간(ms) */
|
||||||
|
const SLIDE_MS = 320
|
||||||
|
|
||||||
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||||
|
default: () => ({
|
||||||
|
announcementEnabled: false,
|
||||||
|
announcementText: '',
|
||||||
|
announcementUrl: '',
|
||||||
|
announcementBackgroundColor: '#15171a',
|
||||||
|
announcementAlignment: 'center',
|
||||||
|
updatedAt: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** DOM에 바를 둘지(애니메이션 종료 전까지 유지) */
|
||||||
|
const inDom = ref(false)
|
||||||
|
/** 펼침(아래로 슬라이드) 애니메이션 상태 */
|
||||||
|
const expanded = ref(false)
|
||||||
|
/** 숨김 처리 완료 여부 */
|
||||||
|
const dismissed = ref(false)
|
||||||
|
|
||||||
|
let closeTimer = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정상 어나운스 바를 켤 수 있는지
|
||||||
|
* @returns {boolean} 가능 여부
|
||||||
|
*/
|
||||||
|
const isEligible = computed(() => {
|
||||||
|
if (!siteSettings.value?.announcementEnabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean((siteSettings.value?.announcementText || '').trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
const announcementText = computed(() => (siteSettings.value?.announcementText || '').trim())
|
||||||
|
|
||||||
|
const announcementLink = computed(() => normalizeAnnouncementUrl(siteSettings.value?.announcementUrl || ''))
|
||||||
|
|
||||||
|
const snoozeLabel = computed(() => `${ANNOUNCEMENT_SNOOZE_DAYS}일간 보지 않기`)
|
||||||
|
|
||||||
|
const announcementAlignment = computed(() => siteSettings.value?.announcementAlignment === 'left' ? 'left' : 'center')
|
||||||
|
|
||||||
|
const barStyle = computed(() => {
|
||||||
|
const backgroundColor = siteSettings.value?.announcementBackgroundColor || '#15171a'
|
||||||
|
return {
|
||||||
|
backgroundColor,
|
||||||
|
color: getAnnouncementBarTextColor(backgroundColor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어나운스 바를 펼친다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const openBar = async () => {
|
||||||
|
inDom.value = true
|
||||||
|
expanded.value = false
|
||||||
|
await nextTick()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
expanded.value = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어나운스 바를 접고 DOM에서 제거한다.
|
||||||
|
* @param {() => void} persistDismiss - localStorage·sessionStorage 저장
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeBar = (persistDismiss) => {
|
||||||
|
if (!inDom.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
persistDismiss()
|
||||||
|
expanded.value = false
|
||||||
|
|
||||||
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeTimer = setTimeout(() => {
|
||||||
|
inDom.value = false
|
||||||
|
dismissed.value = true
|
||||||
|
closeTimer = null
|
||||||
|
}, SLIDE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이번 방문(세션) 동안만 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const dismissForSession = () => {
|
||||||
|
closeBar(() => dismissAnnouncementForSession(siteSettings.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* N일간 보지 않기로 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const dismissForSnooze = () => {
|
||||||
|
closeBar(() => dismissAnnouncementForDays(siteSettings.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => siteSettings.value?.updatedAt, async () => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = isAnnouncementDismissed(siteSettings.value)
|
||||||
|
dismissed.value = hidden
|
||||||
|
|
||||||
|
if (!isEligible.value || hidden) {
|
||||||
|
expanded.value = false
|
||||||
|
inDom.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await openBar()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
dismissed.value = isAnnouncementDismissed(siteSettings.value)
|
||||||
|
|
||||||
|
if (isEligible.value && !dismissed.value) {
|
||||||
|
openBar()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="inDom"
|
||||||
|
class="site-announcement-bar-shell grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none"
|
||||||
|
:class="expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
||||||
|
:style="{ transitionDuration: `${SLIDE_MS}ms` }"
|
||||||
|
>
|
||||||
|
<div class="min-h-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="site-announcement-bar relative z-30 w-full text-center text-sm font-medium"
|
||||||
|
:style="barStyle"
|
||||||
|
role="region"
|
||||||
|
aria-label="사이트 공지"
|
||||||
|
:aria-hidden="(!expanded).toString()"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="site-announcement-bar__inner relative mx-auto flex min-h-9 max-w-[1294px] items-center gap-3 px-4 py-2.5 sm:px-6 lg:px-8"
|
||||||
|
:class="announcementAlignment === 'left' ? 'justify-start' : 'justify-center'"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="announcementLink ? 'a' : 'span'"
|
||||||
|
class="site-announcement-bar__text min-w-0 line-clamp-2 px-6 sm:px-8"
|
||||||
|
:class="[
|
||||||
|
announcementLink ? 'hover:underline' : '',
|
||||||
|
announcementAlignment === 'left' ? 'flex-1 text-left' : 'flex-1 text-center'
|
||||||
|
]"
|
||||||
|
:href="announcementLink || undefined"
|
||||||
|
:target="announcementLink ? '_blank' : undefined"
|
||||||
|
:rel="announcementLink ? 'noreferrer' : undefined"
|
||||||
|
>
|
||||||
|
{{ announcementText }}
|
||||||
|
</component>
|
||||||
|
<div class="site-announcement-bar__actions absolute top-1/2 right-3 flex shrink-0 -translate-y-1/2 items-center gap-2 sm:right-4 lg:right-5">
|
||||||
|
<button
|
||||||
|
class="site-announcement-bar__snooze whitespace-nowrap text-xs font-medium underline-offset-2 opacity-90 transition hover:underline hover:opacity-100 sm:text-[13px]"
|
||||||
|
type="button"
|
||||||
|
@click="dismissForSnooze"
|
||||||
|
>
|
||||||
|
{{ snoozeLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="site-announcement-bar__close inline-flex size-7 items-center justify-center rounded-full opacity-80 transition hover:opacity-100"
|
||||||
|
type="button"
|
||||||
|
aria-label="이번 방문 동안 닫기"
|
||||||
|
@click="dismissForSession"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
316
components/site/SiteHeader.vue
Normal file
316
components/site/SiteHeader.vue
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 메뉴를 닫는다.
|
||||||
|
* @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 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="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>
|
||||||
|
<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="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>
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<circle cx="10" cy="10" r="7"></circle>
|
||||||
|
<line x1="21" y1="21" x2="15" y2="15"></line>
|
||||||
|
</svg>
|
||||||
|
</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">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4 text-[var(--site-text)]"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M367-527q-47-47-47-113t47-113q47-47 113-47t113 47q47 47 47 113t-47 113q-47 47-113 47t-113-47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm296.5-343.5Q560-607 560-640t-23.5-56.5Q513-720 480-720t-56.5 23.5Q400-673 400-640t23.5 56.5Q447-560 480-560t56.5-23.5ZM480-640Zm0 400Z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</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 || 'Guest' }}
|
||||||
|
</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>
|
||||||
48
components/site/SiteTopChrome.vue
Normal file
48
components/site/SiteTopChrome.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup>
|
||||||
|
let resizeObserver = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 크롬(어나운스 바·헤더) 높이를 CSS 변수로 반영한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const syncTopChromeHeight = () => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chrome = document.querySelector('.site-top-chrome')
|
||||||
|
const height = chrome instanceof HTMLElement ? chrome.offsetHeight : 57
|
||||||
|
document.documentElement.style.setProperty('--site-top-chrome-height', `${height}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncTopChromeHeight()
|
||||||
|
window.addEventListener('resize', syncTopChromeHeight)
|
||||||
|
|
||||||
|
const chrome = document.querySelector('.site-top-chrome')
|
||||||
|
if (chrome instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
syncTopChromeHeight()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(chrome)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('resize', syncTopChromeHeight)
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
document.documentElement.style.removeProperty('--site-top-chrome-height')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="site-top-chrome sticky top-0 z-20 shrink-0">
|
||||||
|
<SiteAnnouncementBar />
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
39
composables/createPostSummary.js
Normal file
39
composables/createPostSummary.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 게시물 요약 또는 본문에서 목록·메타용 짧은 텍스트를 만든다.
|
||||||
|
* @param {string} excerpt - 게시물 요약
|
||||||
|
* @param {string} content - 게시물 본문(마크다운)
|
||||||
|
* @param {Object} [options] - 옵션
|
||||||
|
* @param {number} [options.maxLength=160] - 최대 글자 수
|
||||||
|
* @param {boolean} [options.appendEllipsis=true] - 잘린 문자열 끝에 말줄임 추가 여부
|
||||||
|
* @returns {string} 화면 표시용 요약
|
||||||
|
*/
|
||||||
|
export function createPostSummary(excerpt = '', content = '', options = {}) {
|
||||||
|
const maxLength = Number(options.maxLength) > 0 ? Number(options.maxLength) : 160
|
||||||
|
const appendEllipsis = options.appendEllipsis !== false
|
||||||
|
const source = String(excerpt || '').trim() || String(content || '')
|
||||||
|
|
||||||
|
const plainText = source
|
||||||
|
.replace(/```[\s\S]*?```/g, ' ')
|
||||||
|
.replace(/:::[\s\S]*?:::/g, ' ')
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||||
|
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||||
|
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
||||||
|
.replace(/https?:\/\/\S+/g, ' ')
|
||||||
|
.replace(/[#>*_`~|-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
if (!plainText) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plainText.length <= maxLength) {
|
||||||
|
return plainText
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appendEllipsis) {
|
||||||
|
return plainText.slice(0, maxLength).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${plainText.slice(0, maxLength - 3).trim()}...`
|
||||||
|
}
|
||||||
69
composables/formatPostDate.js
Normal file
69
composables/formatPostDate.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 공개 화면용 게시 날짜를 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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자·상세 메타용 날짜·시각을 YYYY.MM.DD 오전/오후 HH:MM 형식으로 변환한다.
|
||||||
|
* @param {string | null | undefined} value - ISO 8601 등 파싱 가능한 날짜 문자열
|
||||||
|
* @returns {string} 빈 문자열 또는 포맷된 날짜·시각
|
||||||
|
*/
|
||||||
|
export function formatPostDateTime(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')
|
||||||
|
const hours = date.getHours()
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const period = hours < 12 ? '오전' : '오후'
|
||||||
|
const hour12 = String(hours % 12 || 12).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day} ${period} ${hour12}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발행 이후 본문·메타 수정이 있었는지 판별한다(동일 초 갱신은 제외).
|
||||||
|
* @param {{ publishedAt?: string | null, updatedAt?: string | null }} post - 게시물
|
||||||
|
* @returns {boolean} 발행 후 수정 여부
|
||||||
|
*/
|
||||||
|
export function wasPostUpdatedAfterPublish(post) {
|
||||||
|
if (!post?.publishedAt || !post?.updatedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedMs = new Date(post.publishedAt).getTime()
|
||||||
|
const updatedMs = new Date(post.updatedAt).getTime()
|
||||||
|
|
||||||
|
if (Number.isNaN(publishedMs) || Number.isNaN(updatedMs)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedMs - publishedMs > 60_000
|
||||||
|
}
|
||||||
45
composables/useAdminRowMenu.js
Normal file
45
composables/useAdminRowMenu.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 관리자 테이블·목록 행 more vert 메뉴 열림 상태
|
||||||
|
* @returns {{ openMenuId: import('vue').Ref<string>, closeMenu: () => void }}
|
||||||
|
*/
|
||||||
|
export const useAdminRowMenu = () => {
|
||||||
|
const openMenuId = ref('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 열린 메뉴를 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeMenu = () => {
|
||||||
|
openMenuId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 바깥 클릭 시 메뉴를 닫는다.
|
||||||
|
* @param {PointerEvent} event - 포인터 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onDocumentPointerDown = (event) => {
|
||||||
|
if (!openMenuId.value || !(event.target instanceof HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('[data-admin-row-menu]')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
openMenuId,
|
||||||
|
closeMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
73
composables/useThemeMode.js
Normal file
73
composables/useThemeMode.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
SITE_THEME_STORAGE_KEY,
|
||||||
|
resolveSiteTheme
|
||||||
|
} from '~/lib/site-theme-init.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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')
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
const fromDocument = document.documentElement.dataset.theme
|
||||||
|
if (fromDocument === 'light' || fromDocument === 'dark') {
|
||||||
|
theme.value = fromDocument
|
||||||
|
} else {
|
||||||
|
const savedTheme = localStorage.getItem(SITE_THEME_STORAGE_KEY)
|
||||||
|
theme.value = resolveSiteTheme(savedTheme, getSystemTheme() === 'dark')
|
||||||
|
applyThemeToDocument(theme.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(theme, (nextTheme) => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(SITE_THEME_STORAGE_KEY, 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 '';
|
||||||
5
db/migrations/023_add_post_featured.sql
Normal file
5
db/migrations/023_add_post_featured.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS posts_is_featured_status_published_at_idx
|
||||||
|
ON posts (is_featured, status, published_at DESC);
|
||||||
7
db/migrations/024_navigation_recommended_location.sql
Normal file
7
db/migrations/024_navigation_recommended_location.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- 추천 사이트용 location 값 추가(우측 사이드 Recommended, 관리자 메뉴 탭과 동일 저장소)
|
||||||
|
ALTER TABLE navigation_items
|
||||||
|
DROP CONSTRAINT IF EXISTS navigation_items_location_check;
|
||||||
|
|
||||||
|
ALTER TABLE navigation_items
|
||||||
|
ADD CONSTRAINT navigation_items_location_check
|
||||||
|
CHECK (location IN ('primary', 'footer', 'recommended'));
|
||||||
10
db/migrations/025_posts_status_no_private.sql
Normal file
10
db/migrations/025_posts_status_no_private.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- 비공개(private) 상태를 초안으로 통합하고 CHECK 제약을 published/draft만 허용하도록 변경한다.
|
||||||
|
|
||||||
|
UPDATE posts
|
||||||
|
SET status = 'draft'
|
||||||
|
WHERE status = 'private';
|
||||||
|
|
||||||
|
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft'));
|
||||||
2
db/migrations/026_site_settings_show_post_updated_at.sql
Normal file
2
db/migrations/026_site_settings_show_post_updated_at.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS show_post_updated_at BOOLEAN NOT NULL DEFAULT false;
|
||||||
4
db/migrations/027_site_settings_home_cover.sql
Normal file
4
db/migrations/027_site_settings_home_cover.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS home_cover_image_url TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS home_cover_title TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS home_cover_text TEXT NOT NULL DEFAULT '';
|
||||||
5
db/migrations/028_site_settings_announcement.sql
Normal file
5
db/migrations/028_site_settings_announcement.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS announcement_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS announcement_text TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS announcement_url TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS announcement_background_color TEXT NOT NULL DEFAULT '#15171a';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS signup_blocked_usernames TEXT NOT NULL DEFAULT '["admin","master","zenn","sori","sori.studio"]';
|
||||||
35
db/migrations/030_analytics_daily_stats.sql
Normal file
35
db/migrations/030_analytics_daily_stats.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS site_analytics_daily (
|
||||||
|
day DATE PRIMARY KEY,
|
||||||
|
page_views INTEGER NOT NULL DEFAULT 0,
|
||||||
|
visitors INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_analytics_daily (
|
||||||
|
day DATE NOT NULL,
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
views INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reads INTEGER NOT NULL DEFAULT 0,
|
||||||
|
visitors INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (day, post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS post_analytics_daily_day_idx
|
||||||
|
ON post_analytics_daily (day DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS analytics_daily_visitors (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
visitor_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_site_uidx
|
||||||
|
ON analytics_daily_visitors (day, visitor_hash)
|
||||||
|
WHERE scope = 'site';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_post_uidx
|
||||||
|
ON analytics_daily_visitors (day, post_id, visitor_hash)
|
||||||
|
WHERE scope = 'post';
|
||||||
30
db/migrations/031_analytics_engagement_and_realtime.sql
Normal file
30
db/migrations/031_analytics_engagement_and_realtime.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
ALTER TABLE site_analytics_daily
|
||||||
|
ADD COLUMN IF NOT EXISTS engaged_views INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS total_engaged_seconds INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE post_analytics_daily
|
||||||
|
ADD COLUMN IF NOT EXISTS engaged_views INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS total_engaged_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS scroll_25 INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS scroll_50 INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS scroll_75 INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS scroll_100 INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS analytics_active_sessions (
|
||||||
|
session_hash TEXT PRIMARY KEY,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
post_id UUID REFERENCES posts(id) ON DELETE SET NULL,
|
||||||
|
post_slug TEXT NOT NULL DEFAULT '',
|
||||||
|
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_scroll_ratio REAL NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS analytics_active_sessions_last_seen_idx
|
||||||
|
ON analytics_active_sessions (last_seen_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS analytics_active_sessions_user_idx
|
||||||
|
ON analytics_active_sessions (user_id)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
24
db/migrations/032_add_post_author.sql
Normal file
24
db/migrations/032_add_post_author.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS author_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
UPDATE posts
|
||||||
|
SET author_id = (
|
||||||
|
SELECT id
|
||||||
|
FROM (
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE user_role IN ('owner', 'admin')
|
||||||
|
OR is_admin = true
|
||||||
|
) privileged_users
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE author_id IS NULL
|
||||||
|
AND (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM users
|
||||||
|
WHERE user_role IN ('owner', 'admin')
|
||||||
|
OR is_admin = true
|
||||||
|
) = 1;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS posts_author_id_idx
|
||||||
|
ON posts (author_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS home_cover_dark_image_url TEXT NOT NULL DEFAULT '';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user