Compare commits
173 Commits
bd71ca860c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bb07b5e03 | |||
| 06271b3674 | |||
| eba7704ab8 | |||
| 95d234a625 | |||
| ed30926250 | |||
| e6669439f3 | |||
| 4b18ee78f0 | |||
| 080be1ef15 | |||
| 34b2d0a4c0 | |||
| b69039c7ff | |||
| f7f09ba3aa | |||
| eee65a3b43 | |||
| 4599e7630d | |||
| 61a872315b | |||
| 03cd95fbd0 | |||
| 806b181d1f | |||
| eb4018f92c | |||
| 664d2f98aa | |||
| 7a357dcabc | |||
| 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 |
11
.env.example
11
.env.example
@@ -10,10 +10,15 @@ DB_PORT=43119
|
||||
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
|
||||
@@ -24,5 +29,11 @@ AVATAR_WEBP_QUALITY=82
|
||||
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
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
|
||||
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
|
||||
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
||||
- 커밋까지 완료한 작업은 사용자가 중단을 요청하지 않는 한 `git push`로 원격에 반영해 푸시 누락을 피한다.
|
||||
|
||||
민감 정보 예시:
|
||||
- 실명
|
||||
|
||||
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>
|
||||
40
app.vue
40
app.vue
@@ -1,5 +1,39 @@
|
||||
<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 || appSiteSettings.value.title,
|
||||
link: appSiteSettings.value.faviconUrl
|
||||
? [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
href: appSiteSettings.value.faviconUrl
|
||||
}
|
||||
]
|
||||
: []
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<div class="site-app" :style="siteAccentStyle">
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,6 +56,36 @@
|
||||
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;
|
||||
}
|
||||
@@ -76,6 +106,13 @@
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
html.admin-settings-document,
|
||||
body.admin-settings-document {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -116,6 +153,27 @@
|
||||
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);
|
||||
@@ -139,7 +197,7 @@
|
||||
|
||||
.site-sidebar {
|
||||
min-height: 0;
|
||||
background: var(--site-panel);
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
@@ -216,6 +274,63 @@
|
||||
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를 상속하지 않는 경우 대비
|
||||
*/
|
||||
@@ -236,3 +351,14 @@
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@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: '게시물 상세 화면의 오른쪽 사이드바 TOC 아래에 표시됩니다.',
|
||||
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>
|
||||
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>
|
||||
3581
components/admin/AdminMarkdownEditor.vue
Normal file
3581
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>
|
||||
@@ -4,45 +4,141 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
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'])
|
||||
const emit = defineEmits(['submit', 'delete'])
|
||||
|
||||
const slugTouched = ref(Boolean(props.initialPage.slug))
|
||||
const blockEditor = ref(null)
|
||||
const mediaItems = ref([])
|
||||
const isMediaPickerOpen = ref(false)
|
||||
const isLoadingMedia = ref(false)
|
||||
const isUploadingFeaturedImage = ref(false)
|
||||
const 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 || '',
|
||||
content: props.initialPage.content || '',
|
||||
featuredImage: props.initialPage.featuredImage || ''
|
||||
status: props.initialPage.status || 'published',
|
||||
renderMode: props.initialPage.renderMode || 'html_document',
|
||||
content: props.initialPage.content || ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
* 한글 음절 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} 슬러그
|
||||
* @returns {string} 영문 슬러그
|
||||
*/
|
||||
const toSlug = (value) => value
|
||||
.normalize('NFC')
|
||||
.split('')
|
||||
.map((char) => romanizeHangulSyllable(char))
|
||||
.join('')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣\s-]/g, '')
|
||||
.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)
|
||||
@@ -59,220 +155,394 @@ const touchSlug = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 라이브러리 목록 조회
|
||||
* @returns {Promise<void>}
|
||||
* 페이지 설정 패널을 토글한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const fetchMediaItems = async () => {
|
||||
isLoadingMedia.value = true
|
||||
const toggleSettingsPanel = () => {
|
||||
isSettingsOpen.value = !isSettingsOpen.value
|
||||
}
|
||||
|
||||
try {
|
||||
mediaItems.value = await $fetch('/admin/api/media')
|
||||
} finally {
|
||||
isLoadingMedia.value = false
|
||||
/**
|
||||
* 제목 입력 후 본문 에디터로 이동
|
||||
* @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 openMediaPicker = async () => {
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창 닫기
|
||||
* @returns {void}
|
||||
* HTML 기본 문서 골격을 현재 본문에 채운다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const closeMediaPicker = () => {
|
||||
isMediaPickerOpen.value = false
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
* HTML textarea에서 VS Code식 기본 골격 단축 입력을 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const selectFeaturedImage = (item) => {
|
||||
form.featuredImage = item.url
|
||||
closeMediaPicker()
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 삭제
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeFeaturedImage = () => {
|
||||
form.featuredImage = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 파일 업로드
|
||||
* 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다.
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFeaturedImage = async (event) => {
|
||||
const uploadPageAsset = async (event) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberHtmlCursor()
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('files', files[0])
|
||||
isUploadingFeaturedImage.value = true
|
||||
isUploadingPageAsset.value = true
|
||||
|
||||
try {
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.featuredImage = result.files?.[0]?.url || ''
|
||||
const uploadedUrl = result.files?.[0]?.url || ''
|
||||
|
||||
if (uploadedUrl && form.renderMode === 'html_document') {
|
||||
await insertTextAtHtmlCursor(uploadedUrl)
|
||||
}
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
isUploadingFeaturedImage.value = false
|
||||
isUploadingPageAsset.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 입력 후 본문 에디터로 이동
|
||||
* @returns {void}
|
||||
* 페이지 입력값을 생성한다.
|
||||
* @returns {Object} 페이지 입력값
|
||||
*/
|
||||
const focusContentEditor = () => {
|
||||
blockEditor.value?.focusFirstBlock()
|
||||
}
|
||||
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', {
|
||||
title: form.title.trim(),
|
||||
slug: toSlug(form.slug || form.title),
|
||||
content: form.content,
|
||||
featuredImage: form.featuredImage.trim() || null
|
||||
})
|
||||
emit('submit', createPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 폼 상태를 저장 완료 기준점으로 표시한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const markSaved = () => {
|
||||
savedPageSnapshot.value = serializePageForm()
|
||||
}
|
||||
|
||||
onMounted(markSaved)
|
||||
|
||||
defineExpose({
|
||||
markSaved
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="admin-page-form grid gap-6" @submit.prevent="submitPage">
|
||||
<div class="admin-page-form__main grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<section class="admin-page-form__content grid gap-4">
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="admin-page-form__title-input border-0 bg-transparent px-0 py-2 text-5xl font-semibold leading-tight text-ink outline-none placeholder:text-soft"
|
||||
type="text"
|
||||
placeholder="페이지 제목"
|
||||
required
|
||||
@keydown.enter.prevent="focusContentEditor"
|
||||
>
|
||||
<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__field grid gap-2 text-sm">
|
||||
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="admin-page-form__settings grid content-start gap-4">
|
||||
<label class="admin-page-form__field grid gap-2 text-sm">
|
||||
<span class="admin-page-form__label font-medium">슬러그</span>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
class="admin-page-form__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||
required
|
||||
@input="touchSlug"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="admin-page-form__field grid gap-2 text-sm">
|
||||
<span class="admin-page-form__label font-medium">대표 이미지</span>
|
||||
<figure v-if="form.featuredImage" class="admin-page-form__featured overflow-hidden rounded border border-line bg-white">
|
||||
<img class="admin-page-form__featured-image aspect-[4/3] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
|
||||
<figcaption class="admin-page-form__featured-actions grid gap-2 p-3">
|
||||
<p class="admin-page-form__featured-url break-all text-xs text-muted">
|
||||
{{ form.featuredImage }}
|
||||
</p>
|
||||
<div class="admin-page-form__featured-buttons flex flex-wrap gap-2">
|
||||
<button class="admin-page-form__featured-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker">
|
||||
변경
|
||||
</button>
|
||||
<label class="admin-page-form__featured-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
|
||||
새 업로드
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
<button class="admin-page-form__featured-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeFeaturedImage">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div v-else class="admin-page-form__featured-empty grid gap-2 rounded border border-dashed border-line bg-white p-4">
|
||||
<button class="admin-page-form__featured-select rounded border border-line px-3 py-2 text-sm font-semibold" type="button" @click="openMediaPicker">
|
||||
미디어에서 선택
|
||||
<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>
|
||||
<label class="admin-page-form__featured-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white">
|
||||
{{ isUploadingFeaturedImage ? '업로드 중' : '새 이미지 업로드' }}
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</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>
|
||||
|
||||
<div class="admin-page-form__actions flex justify-end gap-3 border-t border-line pt-5">
|
||||
<NuxtLink class="admin-page-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/pages">
|
||||
취소
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="admin-page-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
>
|
||||
{{ saving ? '저장 중' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isMediaPickerOpen"
|
||||
class="admin-page-form__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeMediaPicker"
|
||||
<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"
|
||||
>
|
||||
<section class="admin-page-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
|
||||
<div class="admin-page-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
||||
<h2 class="admin-page-form__media-picker-title text-lg font-semibold">
|
||||
대표 이미지 선택
|
||||
<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__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
|
||||
닫기
|
||||
<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__media-picker-body max-h-[62vh] overflow-y-auto p-5">
|
||||
<p v-if="isLoadingMedia" class="admin-page-form__media-picker-loading text-sm text-muted">
|
||||
미디어를 불러오는 중입니다.
|
||||
</p>
|
||||
<div v-else-if="mediaItems.length" class="admin-page-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||
<button
|
||||
v-for="item in mediaItems"
|
||||
:key="item.url"
|
||||
class="admin-page-form__media-picker-item overflow-hidden border border-line bg-white text-left"
|
||||
type="button"
|
||||
@click="selectFeaturedImage(item)"
|
||||
>
|
||||
<img class="admin-page-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||
<span class="admin-page-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
|
||||
</button>
|
||||
|
||||
<div 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>
|
||||
<p v-else class="admin-page-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
|
||||
선택할 미디어가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
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>
|
||||
141
components/admin/AdminSlashCommandIcon.vue
Normal file
141
components/admin/AdminSlashCommandIcon.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<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>
|
||||
|
||||
<!-- table -->
|
||||
<template v-else-if="commandId === 'table'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 5.5h16M4 11.5h16M4 17.5h16M8.5 5.5v12M15.5 5.5v12" />
|
||||
<rect x="3" y="4" width="18" height="15" rx="1.5" stroke="currentColor" stroke-width="1.8" />
|
||||
</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>
|
||||
@@ -11,6 +11,14 @@ const props = defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
requireChanges: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
defaultTagType: {
|
||||
type: String,
|
||||
default: 'general'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,6 +33,58 @@ const form = reactive({
|
||||
color: props.initialTag.color || '#15171a'
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그 입력값을 저장 비교용 형태로 정규화한다.
|
||||
* @param {Object} tag - 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 정규화된 태그 입력값
|
||||
*/
|
||||
const normalizeTagPayload = (tag) => ({
|
||||
name: String(tag.name || '').trim(),
|
||||
slug: toSlug(tag.slug || tag.name || ''),
|
||||
description: String(tag.description || '').trim(),
|
||||
sortOrder: Number(tag.sortOrder ?? 0),
|
||||
color: String(tag.color || '#15171a'),
|
||||
tagType: String(tag.tagType || props.defaultTagType)
|
||||
})
|
||||
|
||||
/**
|
||||
* 현재 폼 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
|
||||
*/
|
||||
const currentPayload = computed(() => normalizeTagPayload({
|
||||
name: form.name,
|
||||
slug: form.slug || form.name,
|
||||
description: form.description,
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
}))
|
||||
|
||||
/**
|
||||
* 최초 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 최초 저장 입력값
|
||||
*/
|
||||
const initialPayload = computed(() => normalizeTagPayload({
|
||||
name: props.initialTag.name || '',
|
||||
slug: props.initialTag.slug || props.initialTag.name || '',
|
||||
description: props.initialTag.description || '',
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: props.initialTag.color || '#15171a',
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
}))
|
||||
|
||||
/**
|
||||
* 태그 입력값 변경 여부
|
||||
* @returns {boolean} 변경 여부
|
||||
*/
|
||||
const hasChanges = computed(() => JSON.stringify(currentPayload.value) !== JSON.stringify(initialPayload.value))
|
||||
|
||||
/**
|
||||
* 태그 저장 가능 여부
|
||||
* @returns {boolean} 저장 가능 여부
|
||||
*/
|
||||
const canSubmit = computed(() => !props.saving && (!props.requireChanges || hasChanges.value))
|
||||
|
||||
/**
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
* @param {string} value - 원본 문자열
|
||||
@@ -58,14 +118,11 @@ const touchSlug = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitTag = () => {
|
||||
emit('submit', {
|
||||
name: form.name.trim(),
|
||||
slug: toSlug(form.slug || form.name),
|
||||
description: form.description.trim(),
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: props.initialTag.tagType || 'general'
|
||||
})
|
||||
if (!canSubmit.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', currentPayload.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -128,7 +185,7 @@ const submitTag = () => {
|
||||
<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"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
{{ saving ? '저장 중' : submitLabel }}
|
||||
</button>
|
||||
|
||||
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>
|
||||
@@ -19,6 +19,8 @@ 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)
|
||||
|
||||
/**
|
||||
* 댓글 시간을 상대 시간 형식으로 변환한다.
|
||||
@@ -111,7 +113,7 @@ const markAvatarBroken = (commentId) => {
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
member.value = await $fetch('/api/auth/me?optional=1')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
@@ -313,15 +315,15 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
|
||||
<label for="comment-sort">Sort by:</label>
|
||||
<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">Best</option>
|
||||
<option value="latest">Latest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="best">인기순</option>
|
||||
<option value="latest">최신순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -339,8 +341,8 @@ onMounted(async () => {
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="submitting"
|
||||
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 ? '등록 중...' : '댓글 등록' }}
|
||||
@@ -448,7 +450,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
|
||||
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] p-2">
|
||||
<textarea
|
||||
v-model="replyBody"
|
||||
rows="3"
|
||||
@@ -464,8 +466,8 @@ onMounted(async () => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="submittingReplyId === comment.id"
|
||||
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 ? '등록 중...' : '답글 등록' }}
|
||||
@@ -543,4 +545,3 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
129
components/content/ContentMarkdownCalloutEditor.vue
Normal file
129
components/content/ContentMarkdownCalloutEditor.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<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', 'focus-line'])
|
||||
|
||||
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)"
|
||||
@focus-line="emit('focus-line', $event)"
|
||||
/>
|
||||
</ProseCallout>
|
||||
</div>
|
||||
</template>
|
||||
193
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
193
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<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', 'focus-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"
|
||||
:data-source-line="bodySourceLine - 1"
|
||||
:data-source-line-end="bodySourceLine + bodyLines.length"
|
||||
>
|
||||
<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)"
|
||||
@focus-line="emit('focus-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>
|
||||
1543
components/content/ContentMarkdownEditableInline.vue
Normal file
1543
components/content/ContentMarkdownEditableInline.vue
Normal file
File diff suppressed because it is too large
Load Diff
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>
|
||||
@@ -1,5 +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>
|
||||
<div class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<slot />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -1,21 +1,79 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
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 my-8 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
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'
|
||||
: 'rounded-[10px] border-l-2 border-[var(--site-text)] bg-[var(--site-panel)] px-5 py-4 font-medium'"
|
||||
? '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-[var(--site-text)]', backgroundClass]"
|
||||
>
|
||||
<span class="whitespace-pre-line">
|
||||
<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>
|
||||
|
||||
@@ -53,10 +53,24 @@ const imageSrc = computed(() => props.thumbnail || faviconUrl.value)
|
||||
* @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"
|
||||
@@ -92,4 +106,7 @@ const displayTitle = computed(() => props.title || displayHost.value || props.ur
|
||||
</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>
|
||||
|
||||
@@ -1,7 +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 my-8 rounded-[10px] border border-[var(--site-line)] border-l-[3px] border-l-[var(--site-accent)] bg-[var(--site-panel)] p-5 pl-4 text-[15px] leading-8 text-[var(--site-text)]">
|
||||
<div class="whitespace-pre-line">
|
||||
<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>
|
||||
@@ -59,9 +59,64 @@ const getTweetId = (value) => {
|
||||
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 주소
|
||||
@@ -76,10 +131,74 @@ const tweetEmbedUrl = computed(() => {
|
||||
|
||||
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] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<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"
|
||||
@@ -92,19 +211,36 @@ const tweetEmbedUrl = computed(() => {
|
||||
<iframe
|
||||
v-else-if="tweetEmbedUrl"
|
||||
:key="tweetEmbedUrl"
|
||||
class="prose-embed__tweet min-h-[420px] w-full border-0 sm:min-h-[458px]"
|
||||
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
|
||||
v-else-if="safeExternalUrl"
|
||||
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"
|
||||
:href="url"
|
||||
: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>
|
||||
|
||||
@@ -1,5 +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>
|
||||
<div class="prose-file my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<slot />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -3,6 +3,10 @@ const props = defineProps({
|
||||
level: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +16,9 @@ const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
|
||||
<template>
|
||||
<component
|
||||
:is="tagName"
|
||||
class="prose-heading mt-12 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||
: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,
|
||||
|
||||
@@ -1,33 +1,126 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
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 my-8"
|
||||
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="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
|
||||
<div
|
||||
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="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-[var(--site-muted)]">
|
||||
<slot />
|
||||
<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>
|
||||
|
||||
@@ -10,8 +10,7 @@ defineProps({
|
||||
<template>
|
||||
<component
|
||||
:is="ordered ? 'ol' : 'ul'"
|
||||
class="prose-list my-6 space-y-2 pl-5 text-[15px] leading-8 text-[var(--site-text)] marker:text-[var(--site-muted)]"
|
||||
:class="ordered ? 'list-decimal' : 'list-disc'"
|
||||
class="prose-list mb-2.5 list-none space-y-2 pl-0 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
|
||||
@@ -1,19 +1,112 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
/** 접힌 상태 제목 */
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
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>
|
||||
<details class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<summary class="prose-toggle__summary cursor-pointer text-[15px] font-semibold leading-7 text-[var(--site-text)]">
|
||||
{{ title }}
|
||||
</summary>
|
||||
<div class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]">
|
||||
<slot />
|
||||
<div
|
||||
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>
|
||||
</details>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,5 +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>
|
||||
<div class="prose-video my-8 aspect-video overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<slot />
|
||||
</div>
|
||||
<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>
|
||||
@@ -15,9 +15,127 @@ const { data: tags } = await useFetch('/api/tags', {
|
||||
const { data: navigation } = await useFetch('/api/navigation', {
|
||||
default: () => ({
|
||||
primary: [],
|
||||
footer: []
|
||||
footer: [],
|
||||
recommended: []
|
||||
})
|
||||
})
|
||||
|
||||
/** 저자 영역 공개 여부 */
|
||||
const showAuthorSection = false
|
||||
|
||||
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>
|
||||
@@ -31,20 +149,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
<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">
|
||||
<ul class="flex flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
|
||||
<li
|
||||
v-for="item in navigation.primary"
|
||||
:key="item.id"
|
||||
class="group relative flex w-full items-center"
|
||||
>
|
||||
<NuxtLink
|
||||
class="left-sidebar__nav-link site-panel-hover flex flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] 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 hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]"
|
||||
:to="item.url"
|
||||
>
|
||||
<span class="left-sidebar__nav-link-label flex-1 transition-transform duration-200 group-hover:translate-x-[3px]">{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<SidebarPrimaryNavList :nodes="navigation.primary" />
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +162,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
<NuxtLink
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="left-sidebar__category site-panel-hover group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
|
||||
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 }" />
|
||||
@@ -72,7 +177,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
|
||||
<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>
|
||||
@@ -90,19 +195,22 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="left-sidebar__footer flex shrink-0 items-center justify-between px-4 py-4 text-xs sm:px-5">
|
||||
<nav class="left-sidebar__footer-nav flex gap-4">
|
||||
<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="site-interactive"
|
||||
class="left-sidebar__footer-link site-interactive shrink-0"
|
||||
:to="item.url"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<button
|
||||
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 place-items-center rounded-full"
|
||||
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 ? '라이트 모드' : '다크 모드'"
|
||||
|
||||
@@ -8,27 +8,30 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="post-card site-section site-panel-hover">
|
||||
<article class="post-card site-section site-panel-hover group">
|
||||
<div class="post-card__body site-section-body flex gap-4">
|
||||
<img
|
||||
v-if="post.featuredImage"
|
||||
class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-surface object-cover"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
loading="lazy"
|
||||
>
|
||||
<div v-else class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-[linear-gradient(135deg,#06333a,#f4a261)]" />
|
||||
<PostCardMedia
|
||||
:to="post.to"
|
||||
:title="post.title"
|
||||
:featured-image="post.featuredImage"
|
||||
:thumbnail-image="post.featuredImageThumbnail"
|
||||
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 class="post-card__excerpt mt-2 text-sm leading-6 site-muted">
|
||||
<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 }} / {{ post.tag }}
|
||||
{{ post.publishedAt }}<template v-if="post.tag"> / {{ post.tag }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
69
components/site/PostCardMedia.vue
Normal file
69
components/site/PostCardMedia.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 게시물 링크 */
|
||||
to: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 게시물 제목 */
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 대표 이미지 URL */
|
||||
featuredImage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 목록 표시용 대표 이미지 썸네일 URL */
|
||||
thumbnailImage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 썸네일 비율·크기 Tailwind 클래스 */
|
||||
aspectClass: {
|
||||
type: String,
|
||||
default: 'aspect-square sm:aspect-video'
|
||||
},
|
||||
/** 링크 래퍼 추가 클래스 */
|
||||
linkClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 이미지 추가 클래스 */
|
||||
imageClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const displayImage = computed(() => props.thumbnailImage || props.featuredImage)
|
||||
</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="displayImage"
|
||||
class="post-card-media__image w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
|
||||
:class="[aspectClass, imageClass]"
|
||||
:src="displayImage"
|
||||
: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 max-w-full break-words line-clamp-3 sm:line-clamp-4">{{ title }}</span>
|
||||
</span>
|
||||
</figure>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -1,30 +1,260 @@
|
||||
<script setup>
|
||||
const followLinks = [
|
||||
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
|
||||
{ id: 'x', label: 'X', href: 'https://x.com', icon: 'x' },
|
||||
{ id: 'github', label: 'Github', href: 'https://github.com', icon: 'github' },
|
||||
{ id: 'instagram', label: 'Instagram', href: 'https://instagram.com', icon: 'instagram' },
|
||||
{ id: 'linkedin', label: 'Linkedin', href: 'https://linkedin.com', icon: 'linkedin' },
|
||||
{ id: 'rss', label: 'RSS', href: '/rss/', icon: 'rss' }
|
||||
]
|
||||
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: [],
|
||||
adSidebarCode: '',
|
||||
adPostSidebarCode: '',
|
||||
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?.adPostSidebarCode : 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 scrollContainer = nav.closest('.site-sidebar-scroll')
|
||||
const link = nav.querySelector(`[data-toc-id="${id}"]`)
|
||||
|
||||
if (!(link instanceof HTMLElement) || !(scrollContainer instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const linkRect = link.getBoundingClientRect()
|
||||
const navTop = scrollContainer.scrollTop
|
||||
const navBottom = navTop + scrollContainer.clientHeight
|
||||
const linkTop = navTop + linkRect.top - containerRect.top
|
||||
const linkBottom = linkTop + link.offsetHeight
|
||||
const buffer = 24
|
||||
|
||||
if (linkTop < navTop + buffer) {
|
||||
scrollContainer.scrollTo({
|
||||
top: Math.max(0, linkTop - buffer),
|
||||
behavior: 'smooth'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (linkBottom > navBottom - buffer) {
|
||||
scrollContainer.scrollTo({
|
||||
top: linkBottom - scrollContainer.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-0 max-lg:px-0">
|
||||
<div class="right-sidebar__scroll site-sidebar-scroll flex min-h-0 flex-1 flex-col">
|
||||
<div v-if="!isPostDetailRoute" 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 rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
{{ siteSettings.logoText }}
|
||||
<div class="right-sidebar__logo grid h-12 w-12 shrink-0 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">
|
||||
@@ -35,15 +265,9 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="right-sidebar__subscribe mt-4 flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<input class="right-sidebar__input min-w-0 w-full flex-1 rounded-lg px-3 py-2 text-sm site-input sm:min-w-0" placeholder="Your email">
|
||||
<button class="right-sidebar__button shrink-0 rounded-lg px-4 py-2 text-sm font-semibold site-button sm:self-auto" type="button">
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<div v-if="!isPostDetailRoute && 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
|
||||
@@ -52,11 +276,11 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
<a
|
||||
v-for="item in followLinks"
|
||||
:key="item.id"
|
||||
class="site-interactive p-0.5 hover:opacity-75"
|
||||
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="_blank"
|
||||
rel="noreferrer"
|
||||
:target="item.external ? '_blank' : undefined"
|
||||
:rel="item.external ? 'noreferrer' : undefined"
|
||||
>
|
||||
<svg
|
||||
v-if="item.icon === 'facebook'"
|
||||
@@ -67,7 +291,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
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>
|
||||
@@ -80,7 +304,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
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" />
|
||||
@@ -94,7 +318,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
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>
|
||||
@@ -107,7 +331,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
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" />
|
||||
@@ -122,12 +346,47 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
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"
|
||||
@@ -137,11 +396,10 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
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" />
|
||||
<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>
|
||||
@@ -149,27 +407,89 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<div
|
||||
v-if="isPostDetailRoute"
|
||||
class="right-sidebar__block right-sidebar__toc py-5 pl-5 pr-0 max-lg:hidden"
|
||||
>
|
||||
<div class="right-sidebar__row flex shrink-0 items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
목차
|
||||
</p>
|
||||
</div>
|
||||
<nav ref="tocNavRef" class="right-sidebar__toc-nav mt-4 pr-2" aria-label="게시글 목차">
|
||||
<ul v-if="postTocItems.length" class="right-sidebar__toc-list flex list-none flex-col gap-2 border-l border-[var(--site-line)] p-0">
|
||||
<li
|
||||
v-for="item in postTocItems"
|
||||
:key="item.id"
|
||||
class="right-sidebar__toc-item relative flex min-h-6 items-center transition-colors"
|
||||
:class="activeTocId === item.id ? 'right-sidebar__toc-item--active' : ''"
|
||||
>
|
||||
<a
|
||||
class="right-sidebar__toc-link site-interactive flex min-h-6 w-full items-center rounded-md pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
|
||||
:class="{
|
||||
'bg-[var(--site-panel)] text-[var(--site-accent)] font-semibold': activeTocId === item.id,
|
||||
'text-[var(--site-text)]': activeTocId !== item.id,
|
||||
'pl-4 font-semibold': item.level === 1,
|
||||
'pl-7': item.level === 2,
|
||||
'pl-10': 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>
|
||||
<span>↗</span>
|
||||
</div>
|
||||
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
|
||||
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/post/hello-sori-studio">
|
||||
sori.studio 첫 글과 방향
|
||||
</NuxtLink>
|
||||
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/projects">
|
||||
Projects and services
|
||||
</NuxtLink>
|
||||
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/links">
|
||||
Links and portal
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<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 class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<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>
|
||||
@@ -177,6 +497,12 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
About {{ siteSettings.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<SiteAdSlot
|
||||
class="right-sidebar__ad-slot py-5 pl-5 pr-0 max-lg:px-0"
|
||||
:code="sidebarAdCode"
|
||||
:location="isPostDetailRoute ? 'post-sidebar-right' : 'sidebar'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-3 text-xs site-muted max-lg:px-0">
|
||||
@@ -184,3 +510,26 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
</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;
|
||||
}
|
||||
|
||||
.right-sidebar__toc-item--active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: var(--site-accent);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</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>
|
||||
@@ -65,7 +65,7 @@ const toggleUserMenu = () => {
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
member.value = await $fetch('/api/auth/me?optional=1')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
@@ -151,9 +151,10 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="site-header sticky top-0 z-20 backdrop-blur">
|
||||
<div class="site-header__inner mx-auto flex h-full max-w-[1294px] items-center justify-between gap-3 px-4 sm:gap-4 lg:gap-5 lg:px-5 xl:gap-6 xl:px-6 2xl:px-0">
|
||||
<NuxtLink class="site-header__brand flex min-w-0 shrink-1 items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,32vw)] xl:max-w-[min(320px,28vw)] 2xl:max-w-none 2xl:flex-1" to="/">
|
||||
<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"
|
||||
@@ -165,14 +166,6 @@ onBeforeUnmount(() => {
|
||||
@click.prevent="toggleMenu"
|
||||
>
|
||||
<span v-if="menuOpen" class="site-header__menu-icon pointer-events-none">
|
||||
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
|
||||
</svg>
|
||||
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="site-header__menu-icon pointer-events-none">
|
||||
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
|
||||
<path d="M9 4v16" />
|
||||
@@ -183,20 +176,37 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<button
|
||||
</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 min-w-0 flex-1 basis-0 cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex md:max-w-[min(470px,42vw)] lg:max-w-[min(470px,30vw)] xl:max-w-[min(470px,36vw)] 2xl:w-[470px] 2xl:max-w-[470px] 2xl:basis-auto 2xl:flex-none site-input"
|
||||
class="site-header__search site-header__search--responsive hidden h-9 w-full min-w-[470px] max-w-[min(470px,100%)] cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex site-input"
|
||||
aria-label="검색 열기"
|
||||
@click="openSearchModal"
|
||||
>
|
||||
<span class="site-header__search-icon mr-2 text-lg leading-none">⌕</span>
|
||||
<span class="site-header__search-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>
|
||||
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
|
||||
</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"
|
||||
@@ -213,7 +223,15 @@ onBeforeUnmount(() => {
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
>
|
||||
<span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold">
|
||||
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }}
|
||||
<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>
|
||||
|
||||
@@ -244,7 +262,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="max-w-xs truncate leading-[1.15]">
|
||||
{{ member?.username || 'Anonymous' }}
|
||||
{{ member?.username || 'Guest' }}
|
||||
</div>
|
||||
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
|
||||
{{ member.email }}
|
||||
|
||||
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>
|
||||
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()}...`
|
||||
}
|
||||
@@ -20,3 +20,50 @@ export function formatPostDate(value) {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
const themeStorageKey = 'SITE_THEME'
|
||||
import {
|
||||
SITE_THEME_STORAGE_KEY,
|
||||
resolveSiteTheme
|
||||
} from '~/lib/site-theme-init.js'
|
||||
|
||||
/**
|
||||
* HTML 루트 요소에 현재 테마를 반영한다.
|
||||
@@ -34,19 +37,23 @@ export const useThemeMode = () => {
|
||||
const theme = useState('site-theme-mode', () => 'light')
|
||||
const isDarkMode = computed(() => theme.value === 'dark')
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem(themeStorageKey)
|
||||
const nextTheme = savedTheme === 'light' || savedTheme === 'dark' ? savedTheme : getSystemTheme()
|
||||
theme.value = nextTheme
|
||||
applyThemeToDocument(nextTheme)
|
||||
})
|
||||
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(themeStorageKey, nextTheme)
|
||||
localStorage.setItem(SITE_THEME_STORAGE_KEY, nextTheme)
|
||||
applyThemeToDocument(nextTheme)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,15 +15,24 @@ 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)
|
||||
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)
|
||||
ON CONFLICT DO NOTHING;
|
||||
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
|
||||
);
|
||||
|
||||
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 '';
|
||||
14
db/migrations/034_add_page_render_mode.sql
Normal file
14
db/migrations/034_add_page_render_mode.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE pages
|
||||
ADD COLUMN IF NOT EXISTS render_mode TEXT NOT NULL DEFAULT 'markdown';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'pages_render_mode_check'
|
||||
) THEN
|
||||
ALTER TABLE pages
|
||||
ADD CONSTRAINT pages_render_mode_check CHECK (render_mode IN ('markdown', 'html_document'));
|
||||
END IF;
|
||||
END $$;
|
||||
2
db/migrations/035_default_pages_to_html_document.sql
Normal file
2
db/migrations/035_default_pages_to_html_document.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE pages
|
||||
ALTER COLUMN render_mode SET DEFAULT 'html_document';
|
||||
15
db/migrations/036_content_visibility_statuses.sql
Normal file
15
db/migrations/036_content_visibility_statuses.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_status_check;
|
||||
|
||||
ALTER TABLE posts
|
||||
ADD CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft', 'members', 'private'));
|
||||
|
||||
ALTER TABLE pages
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'published';
|
||||
|
||||
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_status_check;
|
||||
|
||||
ALTER TABLE pages
|
||||
ADD CONSTRAINT pages_status_check CHECK (status IN ('published', 'draft', 'private'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS pages_status_updated_at_idx
|
||||
ON pages (status, updated_at DESC);
|
||||
6
db/migrations/037_add_vip_member_role.sql
Normal file
6
db/migrations/037_add_vip_member_role.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
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', 'vip', 'member'));
|
||||
18
db/migrations/038_restore_owner_when_missing.sql
Normal file
18
db/migrations/038_restore_owner_when_missing.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
WITH fallback_owner AS (
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE user_role = 'admin'
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE users
|
||||
SET
|
||||
user_role = 'owner',
|
||||
is_admin = true,
|
||||
updated_at = now()
|
||||
WHERE id IN (SELECT id FROM fallback_owner)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE user_role = 'owner'
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE IF NOT EXISTS page_analytics_daily (
|
||||
day DATE NOT NULL,
|
||||
page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
visitors INTEGER NOT NULL DEFAULT 0,
|
||||
engaged_views INTEGER NOT NULL DEFAULT 0,
|
||||
total_engaged_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
scroll_25 INTEGER NOT NULL DEFAULT 0,
|
||||
scroll_50 INTEGER NOT NULL DEFAULT 0,
|
||||
scroll_75 INTEGER NOT NULL DEFAULT 0,
|
||||
scroll_100 INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (day, page_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS page_analytics_daily_day_idx
|
||||
ON page_analytics_daily (day DESC);
|
||||
|
||||
ALTER TABLE analytics_daily_visitors
|
||||
ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES pages(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE analytics_daily_visitors
|
||||
DROP CONSTRAINT IF EXISTS analytics_daily_visitors_scope_check;
|
||||
|
||||
ALTER TABLE analytics_daily_visitors
|
||||
ADD CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post', 'page'));
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_page_uidx
|
||||
ON analytics_daily_visitors (day, page_id, visitor_hash)
|
||||
WHERE scope = 'page';
|
||||
|
||||
ALTER TABLE analytics_active_sessions
|
||||
ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS page_slug TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE navigation_items
|
||||
ADD COLUMN IF NOT EXISTS description_text TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS thumbnail_url TEXT NOT NULL DEFAULT '';
|
||||
47
db/migrations/040_post_export_jobs.sql
Normal file
47
db/migrations/040_post_export_jobs.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE IF NOT EXISTS post_export_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
requested_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
requested_email TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
scope TEXT NOT NULL DEFAULT 'all',
|
||||
post_count INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_size INTEGER NOT NULL DEFAULT 100,
|
||||
retention_days INTEGER NOT NULL DEFAULT 100,
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '100 days'),
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
CONSTRAINT post_export_jobs_status_check CHECK (status IN ('queued', 'processing', 'ready', 'failed', 'expired')),
|
||||
CONSTRAINT post_export_jobs_scope_check CHECK (scope IN ('all', 'author')),
|
||||
CONSTRAINT post_export_jobs_chunk_size_check CHECK (chunk_size > 0),
|
||||
CONSTRAINT post_export_jobs_retention_days_check CHECK (retention_days > 0 AND retention_days <= 100)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_export_jobs_status_created_at_idx
|
||||
ON post_export_jobs (status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_export_jobs_expires_at_idx
|
||||
ON post_export_jobs (expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_export_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_id UUID NOT NULL REFERENCES post_export_jobs(id) ON DELETE CASCADE,
|
||||
part_index INTEGER NOT NULL,
|
||||
post_start INTEGER NOT NULL,
|
||||
post_end INTEGER NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
file_size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
CONSTRAINT post_export_files_status_check CHECK (status IN ('pending', 'processing', 'ready', 'failed', 'expired')),
|
||||
CONSTRAINT post_export_files_range_check CHECK (post_start > 0 AND post_end >= post_start),
|
||||
CONSTRAINT post_export_files_part_index_check CHECK (part_index > 0),
|
||||
CONSTRAINT post_export_files_job_part_unique UNIQUE (job_id, part_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_export_files_job_id_part_index_idx
|
||||
ON post_export_files (job_id, part_index ASC);
|
||||
15
db/migrations/041_post_export_progress.sql
Normal file
15
db/migrations/041_post_export_progress.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD COLUMN IF NOT EXISTS processed_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD COLUMN IF NOT EXISTS current_part_index INTEGER;
|
||||
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD COLUMN IF NOT EXISTS progress_message TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD CONSTRAINT post_export_jobs_processed_count_check
|
||||
CHECK (processed_count >= 0);
|
||||
8
db/migrations/042_post_export_date_range.sql
Normal file
8
db/migrations/042_post_export_date_range.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD COLUMN IF NOT EXISTS date_from TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD COLUMN IF NOT EXISTS date_to TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD COLUMN IF NOT EXISTS range_label TEXT NOT NULL DEFAULT '전체';
|
||||
9
db/migrations/043_post_export_size_and_error_detail.sql
Normal file
9
db/migrations/043_post_export_size_and_error_detail.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD COLUMN IF NOT EXISTS max_file_size_bytes BIGINT NOT NULL DEFAULT 524288000;
|
||||
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD COLUMN IF NOT EXISTS error_detail TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE post_export_jobs
|
||||
ADD CONSTRAINT post_export_jobs_max_file_size_bytes_check
|
||||
CHECK (max_file_size_bytes >= 10485760);
|
||||
4
db/migrations/044_site_settings_custom_code.sql
Normal file
4
db/migrations/044_site_settings_custom_code.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS ads_txt TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS custom_head_code TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS custom_footer_code TEXT NOT NULL DEFAULT '';
|
||||
42
db/migrations/045_analytics_traffic_sources.sql
Normal file
42
db/migrations/045_analytics_traffic_sources.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
CREATE TABLE IF NOT EXISTS analytics_traffic_daily (
|
||||
day DATE NOT NULL,
|
||||
source_group TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
os_name TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL DEFAULT '',
|
||||
page_views INTEGER NOT NULL DEFAULT 0,
|
||||
visitors INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (day, source_group, source_name, device_type, os_name, keyword)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS analytics_traffic_daily_day_idx
|
||||
ON analytics_traffic_daily (day DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS analytics_traffic_daily_source_idx
|
||||
ON analytics_traffic_daily (source_group, source_name);
|
||||
|
||||
ALTER TABLE analytics_daily_visitors
|
||||
ADD COLUMN IF NOT EXISTS source_group TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS source_name TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS device_type TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS os_name TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS keyword TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE analytics_daily_visitors
|
||||
DROP CONSTRAINT IF EXISTS analytics_daily_visitors_scope_check;
|
||||
|
||||
ALTER TABLE analytics_daily_visitors
|
||||
ADD CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post', 'page', 'traffic'));
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_traffic_uidx
|
||||
ON analytics_daily_visitors (
|
||||
day,
|
||||
visitor_hash,
|
||||
source_group,
|
||||
source_name,
|
||||
device_type,
|
||||
os_name,
|
||||
keyword
|
||||
)
|
||||
WHERE scope = 'traffic';
|
||||
2
db/migrations/046_site_settings_brand_color.sql
Normal file
2
db/migrations/046_site_settings_brand_color.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS brand_color TEXT NOT NULL DEFAULT '#ff4f2e';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS announcement_alignment TEXT NOT NULL DEFAULT 'center';
|
||||
2
db/migrations/048_site_settings_social_links.sql
Normal file
2
db/migrations/048_site_settings_social_links.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS social_links JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
5
db/migrations/049_fix_social_links_jsonb_string.sql
Normal file
5
db/migrations/049_fix_social_links_jsonb_string.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
UPDATE site_settings
|
||||
SET social_links = (social_links #>> '{}')::jsonb
|
||||
WHERE jsonb_typeof(social_links) = 'string'
|
||||
AND (social_links #>> '{}') IS NOT NULL
|
||||
AND (social_links #>> '{}') ~ '^\s*\[';
|
||||
5
db/migrations/050_site_settings_ad_slots.sql
Normal file
5
db/migrations/050_site_settings_ad_slots.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS ad_home_feed_code TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ad_sidebar_code TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ad_post_top_code TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ad_post_bottom_code TEXT NOT NULL DEFAULT '';
|
||||
2
db/migrations/051_site_settings_home_infeed_ad.sql
Normal file
2
db/migrations/051_site_settings_home_infeed_ad.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS ad_home_infeed_code TEXT NOT NULL DEFAULT '';
|
||||
2
db/migrations/052_site_settings_post_in_article_ad.sql
Normal file
2
db/migrations/052_site_settings_post_in_article_ad.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS ad_post_in_article_code TEXT NOT NULL DEFAULT '';
|
||||
2
db/migrations/053_site_settings_post_sidebar_ad.sql
Normal file
2
db/migrations/053_site_settings_post_sidebar_ad.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS ad_post_sidebar_code TEXT NOT NULL DEFAULT '';
|
||||
2
db/migrations/054_add_post_show_featured_image.sql
Normal file
2
db/migrations/054_add_post_show_featured_image.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS show_featured_image BOOLEAN NOT NULL DEFAULT false;
|
||||
21
db/migrations/055_add_post_tag_sort_order.sql
Normal file
21
db/migrations/055_add_post_tag_sort_order.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE post_tags
|
||||
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
WITH ordered_post_tags AS (
|
||||
SELECT
|
||||
post_id,
|
||||
tag_id,
|
||||
(ROW_NUMBER() OVER (
|
||||
PARTITION BY post_id
|
||||
ORDER BY created_at ASC, tag_id ASC
|
||||
) - 1) * 10 AS next_sort_order
|
||||
FROM post_tags
|
||||
)
|
||||
UPDATE post_tags
|
||||
SET sort_order = ordered_post_tags.next_sort_order
|
||||
FROM ordered_post_tags
|
||||
WHERE post_tags.post_id = ordered_post_tags.post_id
|
||||
AND post_tags.tag_id = ordered_post_tags.tag_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_tags_post_id_sort_order_idx
|
||||
ON post_tags (post_id, sort_order ASC, created_at ASC);
|
||||
10
db/migrations/056_site_settings_post_tag_limit.sql
Normal file
10
db/migrations/056_site_settings_post_tag_limit.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS post_tag_limit INTEGER NOT NULL DEFAULT 5;
|
||||
|
||||
ALTER TABLE site_settings
|
||||
ADD CONSTRAINT site_settings_post_tag_limit_range_check
|
||||
CHECK (post_tag_limit BETWEEN 1 AND 10)
|
||||
NOT VALID;
|
||||
|
||||
ALTER TABLE site_settings
|
||||
VALIDATE CONSTRAINT site_settings_post_tag_limit_range_check;
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
- ./public/uploads:/app/public/uploads
|
||||
depends_on:
|
||||
- sori-studio-db
|
||||
networks:
|
||||
- sori-studio-network
|
||||
restart: unless-stopped
|
||||
|
||||
sori-studio-db:
|
||||
@@ -27,8 +29,18 @@ services:
|
||||
- "${DB_PORT:-43119}:5432"
|
||||
volumes:
|
||||
- sori-studio-postgres:/var/lib/postgresql/data
|
||||
# NAS 등: 호스트 db/migrations 가 다른 UID만 읽을 수 있으면 컨테이너에서 Permission denied → DB 재시작 루프. 프로젝트 루트에서 chmod -R a+rX db/migrations 및 상위 경로 통과 권한 확인.
|
||||
- ./db/migrations:/docker-entrypoint-initdb.d:ro
|
||||
networks:
|
||||
- sori-studio-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
sori-studio-postgres:
|
||||
|
||||
networks:
|
||||
sori-studio-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${DOCKER_SUBNET:-10.250.50.0/24}
|
||||
|
||||
@@ -1,5 +1,684 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.104
|
||||
|
||||
- 게시물 상세 목차에서 긴 제목이 2줄 이상으로 표시되어도 활성 왼쪽 라인이 항목 높이에 맞게 표시되도록 조정했다.
|
||||
|
||||
## v1.5.103
|
||||
|
||||
- 게시물 상세 목차의 활성 왼쪽 라인을 scoped CSS로 직접 그려 표시 안정성을 높였다.
|
||||
|
||||
## v1.5.102
|
||||
|
||||
- 게시물 상세 목차 항목의 높이와 세로 정렬을 맞추고, 활성 표시선이 브랜드 컬러로 다시 보이도록 조정했다.
|
||||
|
||||
## v1.5.101
|
||||
|
||||
- 게시물 상세 목차의 활성 표시선이 생겨도 항목 텍스트 시작점이 흔들리지 않게 조정했다.
|
||||
|
||||
## v1.5.100
|
||||
|
||||
- 게시물 상세 목차의 왼쪽 라인을 항목별 보더로 바꿔 활성 위치가 브랜드 컬러로 더 명확하게 보이게 했다.
|
||||
|
||||
## v1.5.99
|
||||
|
||||
- 게시물 상세 목차 라벨을 한글로 바꾸고, 활성 목차 항목을 브랜드 컬러로 더 또렷하게 표시했다.
|
||||
|
||||
## v1.5.98
|
||||
|
||||
- 게시물 상세 TOC가 별도 높이 제한 없이 전체 목차를 먼저 보여 주고, 게시물 사이드 광고는 그 아래에 이어지도록 바꿨다.
|
||||
|
||||
## v1.5.97
|
||||
|
||||
- 게시물 상세 오른쪽 사이드바에서 소개·Follow를 숨기고 TOC를 최상단으로 올렸다.
|
||||
- 게시물 사이드 광고는 왼쪽 사이드바에서 오른쪽 TOC 아래로 이동했다.
|
||||
- TOC에 세로 기준선과 활성 항목 표시선을 추가하고, 광고 영역을 위해 높이를 제한했다.
|
||||
|
||||
## v1.5.96
|
||||
|
||||
- 비로그인 상태로 공개 사이트를 볼 때 회원/관리자 세션 확인 요청의 401 콘솔 로그가 반복 표시되지 않게 했다.
|
||||
|
||||
## v1.5.95
|
||||
|
||||
- 게시물 상세의 왼쪽 사이드 광고와 오른쪽 TOC 영역에서 내용 없이 구분선만 보이는 상황을 줄였다.
|
||||
|
||||
## v1.5.94
|
||||
|
||||
- 게시물 상세 오른쪽 사이드바 TOC가 아래 빈 공간까지 사용해 긴 목차를 더 많이 볼 수 있다.
|
||||
|
||||
## v1.5.93
|
||||
|
||||
- 글쓰기 태그는 기본 최대 5개까지 선택할 수 있으며, 사이트 설정에서 1~10개 범위로 조절할 수 있게 했다.
|
||||
- 게시물 저장 API도 설정된 태그 최대 개수를 초과하면 저장을 막는다.
|
||||
- `/표` 또는 `/table` 슬래시 명령으로 기본 표를 삽입할 수 있고, 공개 본문에서 마크다운 표가 표 형태로 렌더링된다.
|
||||
|
||||
## v1.5.92
|
||||
|
||||
- 새 태그 저장 후 태그 목록으로 돌아가고, 저장·삭제 결과를 토스트로 확인할 수 있게 했다.
|
||||
- 태그 수정의 변경 저장 버튼은 실제 변경사항이 있을 때만 활성화된다.
|
||||
- 글쓰기 미디어 모달은 업로드 완료 후 자동 삽입·자동 닫기를 하지 않고, 목록에서 직접 선택해 삽입하도록 했다.
|
||||
- 미디어 업로드 중에는 추가 업로드와 닫기를 막는다.
|
||||
|
||||
## v1.5.91
|
||||
|
||||
- 글쓰기 본문 미디어 선택 창에서 카드 썸네일 파생 이미지가 중복으로 보이지 않게 했다.
|
||||
- 미디어 업로드 중에는 추가 드롭·파일 선택을 막고 업로드 중 로딩 표시를 보여 주도록 했다.
|
||||
- 공개 게시물 목록은 첫 번째 태그를 기준으로 표시하고, 관리자 게시물 목록은 적용된 태그 전체를 보여 주도록 정리했다.
|
||||
|
||||
## v1.5.90
|
||||
|
||||
- 라이브 글쓰기에서 코드블럭을 빠져나온 뒤에도 오른쪽 코드블럭 설정 패널이 남아 있던 문제를 다시 수정했다.
|
||||
- 마지막 코드블럭 아래로 이동해 새 문단을 만들 때 패널 상태가 즉시 일반 문단으로 바뀌도록 보강했다.
|
||||
|
||||
## v1.5.89
|
||||
|
||||
- 코드블럭 안의 `/volume1/...` 같은 경로가 슬래시 명령으로 오인되던 문제를 수정했다.
|
||||
- 글쓰기 에디터에서 `Cmd/Ctrl+Z` 되돌리기와 다시 실행을 지원하도록 보강했다.
|
||||
|
||||
## v1.5.88
|
||||
|
||||
- 라이브 글쓰기에서 마지막 인용문 아래 방향키 입력 시 일반 문단으로 빠져나가지 못하던 문제를 수정했다.
|
||||
|
||||
## v1.5.87
|
||||
|
||||
- 라이브 글쓰기에서 코드블럭을 벗어난 뒤에도 오른쪽 코드블럭 설정 패널이 남아 있던 문제를 수정했다.
|
||||
|
||||
## v1.5.86
|
||||
|
||||
- 다크 모드에서 인용문 글자가 검게 표시되어 읽기 어려운 문제를 수정했다.
|
||||
|
||||
## v1.5.85
|
||||
|
||||
- 검색 결과용 페이지 제목에서 게시물 제목 뒤 사이트 이름이 자동으로 붙지 않도록 정리했다.
|
||||
|
||||
## v1.5.84
|
||||
|
||||
- 모바일 회원가입 화면에서 이메일 인증번호 입력창 높이가 작게 보이던 문제를 수정했다.
|
||||
|
||||
## v1.5.83
|
||||
|
||||
- 오른쪽 사이드바 사이트 로고가 좁게 눌려 보이던 문제를 수정했다.
|
||||
|
||||
## v1.5.82
|
||||
|
||||
- `/sitemap.xml`을 추가해 공개 게시물·페이지·태그 URL을 검색엔진에 전달할 수 있게 했다.
|
||||
- `/robots.txt`에서 sitemap 위치를 안내하도록 했다.
|
||||
- sitemap은 요청 시점 기준으로 자동 생성되므로 새 게시물마다 파일을 직접 갱신하지 않아도 된다.
|
||||
|
||||
## v1.5.81
|
||||
|
||||
- 글쓰기 오른쪽 사이드에서 `대표 이미지 표시` 토글이 항상 보이고 동작하도록 정리했다.
|
||||
- 대표 이미지를 나중에 추가해도 미리 정한 표시 설정이 유지된다.
|
||||
|
||||
## v1.5.80
|
||||
|
||||
- 본문 첨부 이미지는 업로드만으로 카드 썸네일을 만들지 않도록 정리했다.
|
||||
- 게시물 대표 이미지로 저장된 이미지에만 목록 카드용 썸네일을 생성한다.
|
||||
- 관리자 미디어에서 누락된 대표 이미지 카드 썸네일을 다시 생성할 수 있게 했다.
|
||||
- 게시물 상세의 제목 아래 대표 이미지는 글쓰기 옵션을 켠 경우에만 표시된다.
|
||||
|
||||
## v1.5.79
|
||||
|
||||
- 관리자 미디어에서 카드 썸네일을 별도 탭으로 분리했다.
|
||||
- 카드 썸네일 사용 여부를 원본 대표 이미지 사용처와 연결해 미사용으로 잘못 표시되지 않게 했다.
|
||||
- 썸네일이 없어 목록에서 원본을 불러오는 대표 이미지를 구분해 표시하도록 했다.
|
||||
|
||||
## v1.5.78
|
||||
|
||||
- 게시물 목록 카드에서 원본 대표 이미지 대신 생성된 카드용 썸네일을 우선 사용하도록 개선했다.
|
||||
- 기존 업로드 이미지도 한 번에 카드용 썸네일로 변환할 수 있는 백필 명령을 추가했다.
|
||||
|
||||
## v1.5.77
|
||||
|
||||
- 메인 화면 Latest 목록에서 긴 설명 때문에 메타 정보가 잘리는 문제를 줄였다.
|
||||
|
||||
## v1.5.76
|
||||
|
||||
- 관리자 사이트 설정 좌측 메뉴에 아이콘이 없던 항목에 아이콘을 추가했다.
|
||||
|
||||
## v1.5.75
|
||||
|
||||
- 게시물 작성 화면에서 단어 수, 문자 수, 공백 수, 읽기 시간, 블록 수, 이미지 수를 확인할 수 있게 했다.
|
||||
|
||||
## v1.5.70
|
||||
|
||||
- 라이브 모드 마지막 줄에서 `!!!`로 콜아웃을 만들 때 본문 줄을 안정적으로 확보하도록 수정했다.
|
||||
- 콜아웃·인용 내부 전체 선택 후 Delete가 본문 삭제로 반영되도록 수정했다.
|
||||
- 콜아웃·인용에서 줄바꿈 직후 한글 첫 글자가 자모로 분리되는 문제를 줄였다.
|
||||
|
||||
## v1.5.69
|
||||
|
||||
- 라이브 모드 인용·콜아웃에서 Enter 줄바꿈과 `Cmd+Shift+K` 줄 삭제가 다시 안정적으로 반영되도록 수정했다.
|
||||
- 구조 변경 직후 이전 contenteditable DOM이 다시 저장되는 문제를 줄였다.
|
||||
|
||||
## v1.5.68
|
||||
|
||||
- 라이브 모드에서 Shift+위/아래로 인접 문단을 선택하는 동작을 다시 보강했다.
|
||||
- `$H_2O$`, `$2^8$` 같은 Obsidian식 아래첨자·위첨자 표시를 추가했다.
|
||||
|
||||
## v1.5.67
|
||||
|
||||
- 라이브 모드에서 Shift+방향키로 다음 문단까지 범위 선택이 더 안정적으로 동작하도록 수정했다.
|
||||
- 라이브 모드에서 여러 블록을 선택한 뒤 삭제·잘라내기가 소스 모드처럼 본문에 반영되도록 수정했다.
|
||||
|
||||
## v1.5.66
|
||||
|
||||
- 라이브 모드에서 Shift+방향키로 여러 문단·블록을 한 번에 범위 선택할 수 있게 했다.
|
||||
- 라이브 모드 `Cmd/Ctrl+A`를 현재 블록 전체 선택과 본문 전체 선택으로 나눴다.
|
||||
|
||||
## v1.5.65
|
||||
|
||||
- 라이브 모드 인용·콜아웃 같은 본문 블록에서 한글 마지막 글자 입력 후 Enter 한 번으로 줄바꿈되도록 보강했다.
|
||||
|
||||
## v1.5.64
|
||||
|
||||
- 라이브 모드 편집 영역에서 Shift 범위 선택과 전체 선택이 다시 동작하도록 수정했다.
|
||||
|
||||
## v1.5.63
|
||||
|
||||
- 라이브 모드에서 한글 입력 중 Enter를 눌렀을 때 글자 확정 뒤 줄바꿈·블록 분리가 바로 이어지도록 수정했다.
|
||||
- 문단, 제목, 목록, 인용, 콜아웃, 코드, 토글 편집에서 같은 Enter 동작을 쓰도록 보강했다.
|
||||
|
||||
## v1.5.62
|
||||
|
||||
- 라이브 모드에서 문단을 합친 뒤 Enter로 다시 나눌 때 아래 줄 내용이 복제되던 문제를 수정했다.
|
||||
|
||||
## v1.5.61
|
||||
|
||||
- 콜아웃 본문 첫 줄이 비어 있는 상태에서 소스·라이브 모드를 오갈 때 본문이 사라지던 문제를 수정했다.
|
||||
|
||||
## v1.5.55
|
||||
|
||||
- 소스 모드와 라이브 모드에서 `Cmd+Shift+K` 줄 삭제 단축키가 다시 동작하도록 보강했다.
|
||||
- 소스 모드에서 여러 줄을 선택한 뒤 `Cmd+Shift+K`를 누르면 선택 범위 줄을 함께 삭제한다.
|
||||
|
||||
## v1.5.54
|
||||
|
||||
- 콜아웃을 아이콘·제목 헤더와 아래 본문 구조로 정리했다.
|
||||
- 콜아웃 제목을 오른쪽 블록 설정 패널에서 지정할 수 있게 했다.
|
||||
- 새 콜아웃은 기본적으로 아이콘을 표시하지 않는다.
|
||||
|
||||
## v1.5.53
|
||||
|
||||
- 라이브 콜아웃 본문에서 여러 줄을 `Shift+방향키`로 자연스럽게 선택할 수 있게 했다.
|
||||
- 콜아웃 아이콘을 라이브·사용자 화면 모두 왼쪽 상단에 맞췄다.
|
||||
- 아이콘을 사용하지 않는 콜아웃은 라이브 편집 화면에서도 자리 표시자를 남기지 않는다.
|
||||
|
||||
## v1.5.52
|
||||
|
||||
- 연속 콜아웃에서 위 콜아웃을 편집하면 아래 콜아웃 선언 줄이 사라지던 문제를 수정했다.
|
||||
- `:::` fenced 블록의 원본 줄 범위를 닫는 줄까지만 잡도록 보정했다.
|
||||
- 한글 조합 직후 Enter 중복 입력 차단을 더 강하게 적용했다.
|
||||
|
||||
## v1.5.51
|
||||
|
||||
- 라이브 인용·콜아웃에서 한글 입력 후 Enter가 줄을 2개 만들던 문제를 다시 보정했다.
|
||||
- 콜아웃 마지막 줄에서 아래 방향키를 눌러도 새 본문 줄이 생기지 않도록 했다.
|
||||
- 인용은 마지막 줄 아래 방향키에서만 외부 문단을 만들도록 동작을 분리했다.
|
||||
|
||||
## v1.5.50
|
||||
|
||||
- 라이브 작성 모드에서 한글 인용문 Enter가 외부 문단으로 빠지지 않고 다음 인용 줄을 만들도록 보강했다.
|
||||
- 마지막 인용 줄에서 아래 방향키로 외부 문단을 만들며 빠져나갈 수 있게 했다.
|
||||
- 콜아웃 본문을 줄 단위로 편집해 현재 줄 삭제와 한글 Enter 줄 추가가 더 안정적으로 동작하도록 했다.
|
||||
|
||||
## v1.5.49
|
||||
|
||||
- 라이브 작성 모드에서 코드·콜아웃·토글 내부 `Cmd+Shift+K` 줄 삭제가 동작하도록 수정했다.
|
||||
- 콜아웃 내부 줄 삭제와 아래 방향키 이탈 동작을 보강했다.
|
||||
- `/콜아웃` Enter 생성 시 한글 조합 잔여 문자가 남을 가능성을 줄였다.
|
||||
|
||||
## v1.5.48
|
||||
|
||||
- 게시물 작성 화면 상단의 상태 표시는 텍스트만 남기고, 게시물 보기 링크는 오른쪽 `View Post` 기능으로 통일했다.
|
||||
|
||||
## v1.5.47
|
||||
|
||||
- RSS 피드에 게시물 썸네일 정보를 직접 포함해 RSS 리더에서 대표 이미지가 더 안정적으로 보이도록 했다.
|
||||
- RSS의 상대 이미지 경로를 절대 URL로 변환한다.
|
||||
|
||||
## v1.5.46
|
||||
|
||||
- 콜아웃 배경색도 인용 블록과 같은 6색 팔레트로 맞추고 분홍 선택지를 제거했다.
|
||||
- 라이브 작성 모드에서 방향키로 위에서 아래로 이동할 때 콜아웃·인용 블록에 진입하지 못하던 문제를 수정했다.
|
||||
- 작은 화면에서 게시물 설정 패널이 본문을 압축하지 않고 오른쪽 오버레이로 뜨도록 정리했다.
|
||||
|
||||
## v1.5.45
|
||||
|
||||
- 인용 블록 기본 색상을 회색으로 바꾸고 분홍 선택지를 제거했다.
|
||||
- 인용 색상 선택 배지를 실제 인용 블록 색상과 맞췄다.
|
||||
- 라이브 작성 모드의 콜아웃·인용 설정을 소스 모드와 같은 오른쪽 패널 방식으로 통일했다.
|
||||
|
||||
## v1.5.44
|
||||
|
||||
- 관리자 사이트 설정의 제목·설명, 사이트 정보, 사이트 코드 읽기 화면을 긴 문구가 잘리지 않는 14px 라벨/값 행 레이아웃으로 정리했다.
|
||||
- 사이트 로고 미등록 상태는 별도 “등록됨” 문구 없이 점선 미등록 박스로 표시한다.
|
||||
|
||||
## v1.5.43
|
||||
|
||||
- `/rss.xml`, `/feed.xml`, `/rss`에서 최근 공개 발행글 RSS 피드를 제공한다.
|
||||
- SNS 설정의 RSS 프리셋 기본 주소를 `/rss.xml`로 맞췄다.
|
||||
|
||||
## v1.5.42
|
||||
|
||||
- 직접 SVG로 등록한 SNS 아이콘도 기존 SNS 아이콘과 같은 크기와 중앙 정렬로 표시되게 정리했다.
|
||||
|
||||
## v1.5.41
|
||||
|
||||
- SNS 링크가 저장 후 사라져 보이던 문제를 수정했다.
|
||||
- SNS 링크 편집 화면을 아이콘과 주소 중심으로 단순화했다.
|
||||
|
||||
## v1.5.40
|
||||
|
||||
- SNS 링크 주소 입력 시 `https://`를 생략해도 자동으로 보정된다.
|
||||
- SNS 아이콘 프리셋에 없는 서비스는 직접 SVG 아이콘을 등록해 사용할 수 있게 했다.
|
||||
|
||||
## v1.5.39
|
||||
|
||||
- 관리자 사이트 설정에서 SNS 링크를 아이콘 프리셋과 주소 목록으로 관리할 수 있게 했다.
|
||||
- 공개 오른쪽 사이드바 FOLLOW 영역은 등록된 SNS 링크가 있을 때만 표시된다.
|
||||
|
||||
## v1.5.38
|
||||
|
||||
- 어나운스 바 배경색을 직접 선택·입력할 수 있게 했다.
|
||||
- 어나운스 바 문구를 중앙 또는 왼쪽 정렬로 표시할 수 있게 했다.
|
||||
- 한글 입력 중 코드 블록·콜아웃·토글 설정 패널이 줄바꿈 뒤 닫히던 문제를 보강했다.
|
||||
|
||||
## v1.5.37
|
||||
|
||||
- 게시물 글쓰기 오른쪽 블록 설정 패널에서 콜아웃, 코드 블록, 토글 옵션을 수정할 수 있게 했다.
|
||||
- 토글 블록은 기본 펼침 또는 기본 닫힘 상태를 저장할 수 있게 했다.
|
||||
- 파일 다운로드 카드에서 반복되는 파일명 표시를 줄이고 용량 중심으로 보이게 정리했다.
|
||||
|
||||
## v1.5.36
|
||||
|
||||
- 관리자 사이트 설정에서 브랜드 포인트 컬러를 지정할 수 있게 했다.
|
||||
- 지정한 브랜드 컬러가 공개 화면의 활성 네비게이션, TOC, 댓글 버튼 등에 반영된다.
|
||||
- 게시물 글쓰기에서 인용문 블록 배경색을 오른쪽 블록 패널로 선택할 수 있게 했다.
|
||||
|
||||
## v1.5.35
|
||||
|
||||
- 관리자 대시보드에 방문자 유입 정보, 디바이스 통계, 유입 키워드 영역을 추가했다.
|
||||
- 인기 게시물 목록에서 월간 조회수와 작성일을 함께 확인할 수 있게 했다.
|
||||
- 페이지뷰 통계가 유입원과 디바이스를 일별 축약 집계하도록 개선했다.
|
||||
|
||||
## v1.5.34
|
||||
|
||||
- 공개 404/오류 페이지를 추가했다.
|
||||
- 관리자 사이트 설정에서 ads.txt, 공통 헤더 코드, 공통 푸터 코드를 저장하고 공개 페이지에 반영할 수 있게 했다.
|
||||
- gethomepage 커스텀 위젯용 사이트 통계 API를 추가했다.
|
||||
|
||||
## v1.5.33
|
||||
|
||||
- 준비 완료된 내보내기 작업에서는 상태 배지를 표시하지 않도록 정리했다.
|
||||
|
||||
## v1.5.32
|
||||
|
||||
- 최근 내보내기 작업 카드에서 불필요한 요청일·분할 설정 정보를 줄이고 만료일 중심으로 정리했다.
|
||||
- 완료된 백업은 진행도 박스를 숨기고, 파일 체크 선택 후 선택한 ZIP만 내려받을 수 있게 했다.
|
||||
|
||||
## v1.5.31
|
||||
|
||||
- 게시물 내보내기 설정 카드와 다운로드 가능한 최근 작업 카드를 분리해 백업 요청과 결과 확인을 명확히 구분했다.
|
||||
- 내보내기 작업이 없을 때는 최근 작업 카드가 표시되지 않도록 정리했다.
|
||||
|
||||
## v1.5.30
|
||||
|
||||
- 관리자 설정의 메인 화면 커버 읽기 모드에서 라이트·다크 프리뷰가 카드 밖으로 넘치지 않게 정리했다.
|
||||
- 게시물 백업 도구를 `게시물 내보내기`와 `게시물 가져오기` 카드로 분리했다.
|
||||
- 가져오기는 ZIP 파일 선택 후 `적용`을 눌러야 실행되도록 바꿔 실수 실행을 줄였다.
|
||||
|
||||
## v1.5.29
|
||||
|
||||
- 관리자 설정의 메인 화면 커버를 라이트모드와 다크모드로 나누어 각각 확인하고 변경할 수 있게 했다.
|
||||
- 커버 이미지가 없는 경우 점선 드롭존에서 파일 선택 또는 드래그 앤 드롭으로 업로드할 수 있게 했다.
|
||||
- 사용하지 않는 타임존 설정을 제거하고 `기타 설정`을 `사이트 정보`로 정리했다.
|
||||
|
||||
## v1.5.28
|
||||
|
||||
- 게시물 Import가 Obsidian식 YAML 블록 배열 태그를 읽을 수 있게 했다.
|
||||
- Import 중 ZIP 내부 자산 누락이 있으면 완료 결과에 경고로 표시한다.
|
||||
|
||||
## v1.5.27
|
||||
|
||||
- 게시물 Export ZIP을 관리자 설정에서 다시 Import할 수 있게 했다.
|
||||
- Import 시 Markdown frontmatter를 게시물 메타데이터로 복원하고, ZIP 내부 이미지·파일은 새 업로드 URL로 재매핑한다.
|
||||
- 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 새 슬러그로 가져오도록 정리했다.
|
||||
|
||||
## v1.5.26
|
||||
|
||||
- 관리자 사이트 설정의 게시물 Import/Export 영역을 기본적으로 접힌 액션 중심 UI로 정리했다.
|
||||
- Export 설정과 Import 안내는 각각 버튼을 눌렀을 때만 펼쳐지도록 개선했다.
|
||||
- 설정 화면 셀렉트 화살표 아이콘과 간격을 통일했다.
|
||||
|
||||
## v1.5.25
|
||||
|
||||
- 게시물 Export 분할을 고정 개수 대신 목표 ZIP 용량 기준으로 나누도록 개선했다.
|
||||
- Export 요청 시 목표 ZIP 용량과 ZIP당 최대 게시물 수를 지정할 수 있게 했다.
|
||||
- 실패한 Export 작업의 상세 오류를 관리자 화면에서 확인할 수 있게 했다.
|
||||
|
||||
## v1.5.24
|
||||
|
||||
- 게시물 Export 준비 완료 파일을 한 번에 순차 다운로드할 수 있게 개선했다.
|
||||
- 실패한 Export 작업은 이미 만들어진 파일을 유지하고 실패 지점부터 다시 생성할 수 있게 정리했다.
|
||||
- Export 완료 시 Resend 설정이 있으면 요청 관리자에게 이메일 알림을 보낸다.
|
||||
- 만료된 Export 백업 파일은 작업 목록 조회나 새 요청 시 자동으로 정리된다.
|
||||
|
||||
## v1.5.23
|
||||
|
||||
- 게시물 Export를 전체뿐 아니라 특정년, 특정월, 직접 지정 날짜 범위로 요청할 수 있게 개선했다.
|
||||
- 완료되었거나 실패한 Export 백업 파일을 관리자 화면에서 바로 삭제할 수 있게 정리했다.
|
||||
|
||||
## v1.5.22
|
||||
|
||||
- 게시물 Export가 실제 분할 ZIP 파일을 생성하고, 준비 완료된 파일을 관리자 화면에서 내려받을 수 있도록 개선했다.
|
||||
- Export 생성 중에는 새 요청 버튼이 비활성화되어 중복 작업을 만들지 않도록 정리했다.
|
||||
|
||||
## v1.5.21
|
||||
|
||||
- 게시물 Export 작업 카드에 진행 숫자와 진행률 바를 추가하고, 진행 중 작업이 있으면 자동으로 상태를 새로고침하도록 개선했다.
|
||||
|
||||
## v1.5.20
|
||||
|
||||
- 게시물 Export 작업을 관리자 설정에서 요청하고, 생성될 분할 zip 파일 계획을 확인할 수 있는 1차 기반을 추가했다.
|
||||
|
||||
## v1.5.19
|
||||
|
||||
- 게시물 Export를 대량 게시물에서도 안전하게 처리하도록 백그라운드 분할 생성과 다운로드 만료 정책 기준을 정리했다.
|
||||
|
||||
## v1.5.18
|
||||
|
||||
- 게시물 Import/Export 방향을 URL 유지 방식이 아니라 게시물별 폴더와 로컬 이미지·파일 폴더를 포함하는 백업 번들 구조로 정정했다.
|
||||
|
||||
## v1.5.17
|
||||
|
||||
- 사이트 설정 읽기 모드의 토글이 켜져 있어도 편집 전에는 조작 불가 상태처럼 보이도록 시각 톤을 낮췄다.
|
||||
- 게시물 Import/Export는 Obsidian에서 바로 읽기 쉬운 Markdown frontmatter 형식으로 진행할 수 있도록 구현 방향을 정리했다.
|
||||
|
||||
## v1.5.16
|
||||
|
||||
- 게시글을 직접 스크롤해도 오른쪽 TOC에서 현재 읽는 제목 위치가 강조되도록 개선했다.
|
||||
- TOC 항목이 많을 때 활성 항목을 따라 TOC 영역이 내부 스크롤되도록 정리했다.
|
||||
- 댓글과 답글 등록 버튼은 내용을 입력했을 때만 활성화되도록 정리했다.
|
||||
- 댓글 정렬 라벨을 한글 기준으로 정리하고 답글 입력 영역 스타일을 가볍게 맞췄다.
|
||||
|
||||
## v1.5.15
|
||||
|
||||
- 로그아웃 상태의 사용자 메뉴 버튼도 `?` 대신 사람 아이콘으로 보이도록 수정했다.
|
||||
|
||||
## v1.5.14
|
||||
|
||||
- 모바일 게시글 화면에서는 하단으로 내려간 오른쪽 사이드바의 TOC를 숨겼다.
|
||||
- 로그인 회원의 기본 아바타를 사람 아이콘으로 바꿨다.
|
||||
- 미디어 라이브러리에서 파일을 직접 추가하고, 현재 검색·필터 결과를 전체 선택하거나 선택 삭제할 수 있게 했다.
|
||||
- 미디어 검색창을 글·멤버 검색창과 같은 스타일로 맞췄다.
|
||||
|
||||
## v1.5.13
|
||||
|
||||
- 게시글 목차 클릭 이동을 부드러운 스크롤로 바꿨다.
|
||||
- 목차 이동 시 제목이 고정 헤더에 걸리지 않도록 위치를 보정했다.
|
||||
|
||||
## v1.5.12
|
||||
|
||||
- 게시글 상세 오른쪽 사이드바에서 추천 사이트 대신 본문 목차를 보여주도록 바꿨다.
|
||||
- 본문 H1~H3 제목에 목차 이동용 앵커를 자동으로 부여했다.
|
||||
- 로컬 개발 DB의 소유자 계정을 `zenn`으로 보정했다.
|
||||
|
||||
## v1.5.11
|
||||
|
||||
- 멤버 상세 화면을 보기 모드와 수정 모드로 분리했다.
|
||||
- 멤버 상세 저장 버튼은 변경 사항이 있을 때만 활성화되도록 정리했다.
|
||||
- 멤버 상세 저장 결과를 우측 상단 토스트로 통일했다.
|
||||
- 멤버 목록 검색창을 글 목록 검색창과 같은 스타일로 맞췄다.
|
||||
|
||||
## v1.5.10
|
||||
|
||||
- 멤버 상세에서 변경할 수 없는 등급 셀렉트를 화면에서도 잠그도록 정리했다.
|
||||
- 글 목록에 검색과 작은 대표 이미지 썸네일을 추가했다.
|
||||
- 글 목록 필터 셀렉트 화살표 간격과 아이콘을 통일했다.
|
||||
- 페이지 HTML 문서 모드에서 빈 본문 또는 `!`+Tab으로 기본 HTML 골격을 자동 완성할 수 있게 했다.
|
||||
|
||||
## v1.5.9
|
||||
|
||||
- 관리자 대시보드에서 인기 페이지 통계를 볼 수 있게 했다.
|
||||
- HTML 문서 모드 페이지도 서버에서 조회수를 기록하도록 보강했다.
|
||||
- 추천 사이트에 대체 텍스트와 썸네일 URL을 추가하고, 공개 사이드바 표시에도 반영했다.
|
||||
|
||||
## v1.5.8
|
||||
|
||||
- 소유자가 본인 권한을 직접 낮춰 소유자가 사라지는 상황을 막았다.
|
||||
- 멤버 목록의 상태 열에 등급을 함께 표시하고, 비활성 회원만 보조 상태로 보이도록 정리했다.
|
||||
- 소유자가 없는 DB 상태를 복구하는 마이그레이션을 추가했다.
|
||||
|
||||
## v1.5.7
|
||||
|
||||
- 일반 텍스트 페이지에서도 페이지 형식 선택을 다시 HTML로 되돌릴 수 있게 수정했다.
|
||||
- 일반 텍스트 페이지에서는 HTML 자산 업로드 UI가 보이지 않도록 정리했다.
|
||||
- 멤버 접속 IP 기록이 프록시 헤더를 읽도록 보정했다.
|
||||
|
||||
## v1.5.6
|
||||
|
||||
- 멤버 등급 변경이 저장 버튼을 눌렀을 때만 반영되도록 수정했다.
|
||||
- 관리자 권한 변경 규칙을 강화해 관리자끼리 조작하거나 마지막 소유자를 없앨 수 없도록 막았다.
|
||||
|
||||
## v1.5.5
|
||||
|
||||
- 멤버 등급에 VIP를 추가하고, 멤버십 게시물은 VIP 이상 등급에게만 공개되도록 정리했다.
|
||||
- 관리자 멤버 상세에서 회원 등급을 직접 변경할 수 있게 했다.
|
||||
|
||||
## v1.5.4
|
||||
|
||||
- 게시물에 멤버십·비공개 상태를 추가하고, 공개 화면에서는 상태에 맞는 글만 보이도록 정리했다.
|
||||
- 페이지에도 초안·공개·비공개 상태를 추가하고, 공개 상태 페이지만 `/pages/:slug`에서 응답하도록 바꿨다.
|
||||
|
||||
## v1.5.3
|
||||
|
||||
- 페이지 작성 기본값을 HTML 문서 모드로 바꾸고, 페이지 슬러그도 한글 제목에서 영문으로 자동 생성되도록 개선했다.
|
||||
- 페이지 작성 화면에서 대표 이미지를 제거하고, HTML 자산 업로드 시 업로드 URL을 현재 커서 위치에 바로 넣을 수 있게 했다.
|
||||
|
||||
## v1.5.2
|
||||
|
||||
- 페이지 작성/수정 화면을 게시글 작성 화면처럼 전체 화면 에디터, 상단 저장 툴바, 오른쪽 설정 패널 구조로 변경했다.
|
||||
|
||||
## v1.5.1
|
||||
|
||||
- 고정 페이지에서 전체 HTML 문서를 붙여넣어 `/pages/:slug`에서 단일 랜딩 페이지처럼 보여줄 수 있는 HTML 문서 모드를 추가했다.
|
||||
|
||||
## v1.5.0
|
||||
|
||||
- 관리자 글쓰기 태그 입력을 검색형 선택으로 개선하고, 태그별 색상을 배지에 반영했다.
|
||||
- 관리자 글·페이지 목록의 더보기 메뉴가 테이블 밖에서 잘리지 않도록 수정했다.
|
||||
|
||||
## v1.4.7
|
||||
|
||||
- 글쓰기 라이브 모드에서 문단 이동 시 인라인 마크다운 서식이 사라지던 문제를 수정했다.
|
||||
- 인용 블록에서 `> [!bg=yellow]` 형식으로 배경색을 지정할 수 있다.
|
||||
- 소스 모드에서 라이브 모드로 전환할 때 현재 커서 줄 주변으로 스크롤되도록 보정했다.
|
||||
|
||||
## v1.4.6
|
||||
|
||||
- 관리자 사이트 설정에서 로고와 메인 커버 이미지가 저장 버튼을 통해 반영되도록 정리했다.
|
||||
- 홈 커버 이미지를 라이트모드·다크모드용으로 따로 등록할 수 있다.
|
||||
|
||||
## v1.4.3
|
||||
|
||||
- 관리자 화면이 공개 사이트 다크모드 영향을 받지 않도록 라이트 UI를 분리했다.
|
||||
- 관리자 미디어 라이브러리에 종류·미사용 필터와 비디오 썸네일 미리보기를 추가했다.
|
||||
|
||||
## v1.4.2
|
||||
|
||||
- 글쓰기 소스 모드에서 긴 줄이 자동 줄바꿈될 때 라인 번호가 실제 줄 높이와 어긋나던 문제를 수정했다.
|
||||
- 소스 모드에서 라이브 모드로 전환한 직후에도 현재 줄에 포커스가 유지되도록 보정했다.
|
||||
- 라이브 모드에서 소스 모드로 돌아올 때 현재 작성 위치와 가까운 줄로 커서·스크롤을 복원하도록 보정했다.
|
||||
- 이미지 파일 URL 한 줄을 입력했을 때 임베드가 아니라 이미지로 표시되도록 수정했다.
|
||||
- 라이브 모드 이미지 블록에 편집·삭제 버튼을 추가하고, 편집 버튼을 기존 이미지 설정 패널과 연결했다.
|
||||
- 잘못된 이미지 URL·로드 실패 시에도 최소 높이와 오류 안내 placeholder를 표시한다.
|
||||
- 라이브 모드에서 이미지 블록끼리 드래그해 갤러리로 합치고, 갤러리 이미지를 블록 사이에 드롭해 단일 이미지로 분리할 수 있다.
|
||||
- 단독 이미지 URL 줄도 드래그 갤러리 병합·추가 대상으로 처리한다.
|
||||
- 라이브 모드에서 단일 이미지를 기존 갤러리에 드래그로 추가할 수 있다.
|
||||
- 갤러리는 이미지 수와 실제 비율에 따라 행 너비를 자동 조정한다.
|
||||
- 라이브 모드 갤러리 블록도 키보드 이동과 편집·삭제 버튼 접근을 지원한다.
|
||||
- 라이브 모드 갤러리에서는 개별 이미지별 편집·삭제 버튼을 제공한다.
|
||||
- 갤러리 이미지 추가 모달을 열어도 오른쪽 블록 패널 상태가 유지되며, 패널 바깥 클릭 시 닫힌다.
|
||||
- 다크모드 기본 인용 블록과 공개 본문 리스트 마커 색상을 글쓰기 화면 기준으로 정리했다.
|
||||
- 다크모드에서 좌우 사이드바 배경이 본문 배경과 다르게 튀어 보이지 않도록 통일했다.
|
||||
|
||||
## v1.4.1
|
||||
|
||||
- 관리자에서 비디오 등 대용량 미디어 업로드 시 적용되던 10MB 공통 한도를 종류별로 분리했다(비디오 기본 200MB).
|
||||
- 새 임베드 저장 형식을 단독 URL 한 줄로 통일해 `:::embed`와 URL-only가 섞이는 문제를 줄였다.
|
||||
- 글쓰기 라이브 모드에서 임베드·업로드 미디어 카드를 바로 프리뷰로 표시하고 방향키 이동, 버튼/키보드 삭제를 할 수 있게 했다.
|
||||
- 글쓰기 라이브 모드에서 제목 입력 후 Enter가 원문 편집처럼 보이던 흐름을 수정했다.
|
||||
|
||||
## v1.4.0
|
||||
|
||||
- 본문에서 비디오, 오디오, 파일 다운로드 카드를 렌더링할 수 있도록 확장했다.
|
||||
- X/Twitter 임베드 카드 폭을 조정하고 Mastodon 공개 게시물 임베드의 높이 자동 조절을 추가했다.
|
||||
- 관리자 글쓰기에서 비디오·오디오·파일 업로드를 바로 연결하고, 단독 URL 한 줄을 자동 임베드로 표시한다.
|
||||
|
||||
## v1.2.0
|
||||
|
||||
- 관리자 글 목록 정렬·개수·추천 필터·별 표시, 슬러그·예약 시각 UX를 정리했다.
|
||||
|
||||
## v1.1.19
|
||||
|
||||
- 관리자 글쓰기 헤더에 작성/미리보기 전환, Update 시 발행일 유지, 미디어 검색.
|
||||
- 사이트 설정 기타·POST 카드를 섹션별 편집·저장으로 분리.
|
||||
- 본문 인용·인라인 코드 스타일과 블록 여백을 조정했다.
|
||||
|
||||
## v1.1.18
|
||||
|
||||
- 마크다운 에디터 이미지·갤러리 삽입을 단일 모달(미디어 라이브러리·업로드 탭)로 통합하고, 이미지 너비 툴바를 제거했다.
|
||||
- POST 설정에서 발행 후 수정일 표시를 켜고 끌 수 있으며, 관리자 글 목록·공개 상세에 반영된다.
|
||||
|
||||
## v1.1.16
|
||||
|
||||
- 게시 상태를 초안·발행·예약만 쓰도록 정리하고, 신규 초안 임시 슬러그·발행 UI·툴바 저장 동작을 맞췄다.
|
||||
|
||||
## v1.1.15
|
||||
|
||||
- 신규 초안 서버 자동 저장, 초안 이탈 확인 모달 제거, 글 목록 헤더 필터 배치를 정리했다.
|
||||
|
||||
## v1.1.14
|
||||
|
||||
- 관리자 글쓰기 상단을 Ghost에 가깝게 바꾸고, 초안 자동 저장은 서버 PUT만 쓰며 발행·예약 글은 Update로만 저장되게 정리했다.
|
||||
|
||||
## v1.1.13
|
||||
|
||||
- 상단 메뉴 깊이를 한 단계로 제한하고, 추천 사이트를 DB·관리자 탭·우측 Recommended 카드(외부 파비콘 프록시)로 연결했다.
|
||||
|
||||
## v1.1.12
|
||||
|
||||
- 관리자 상단 메뉴에서 드래그 시 형제 끼움과 하위 편입을 색·문구로 구분하고, 왼쪽 번호를 계층형 개요(`2.1` 등)로 바꿨다.
|
||||
|
||||
## v1.1.11
|
||||
|
||||
- 공개 사이드바 1차 네비 비활성 표시·하위 간격을 정리하고, 관리자 상단 메뉴는 추가 후 드래그만으로 형제 순서·하위 편입을 바꾸도록 단순화했다.
|
||||
|
||||
## v1.1.10
|
||||
|
||||
- 관리자 사이트 설정 화면을 Ghost형 전체 화면(좌측 내비·스크롤 스파이·ESC 닫기)으로 바꾸고, 블로그 제목·설명은 읽기 전용 + 편집 시에만 입력하도록 정리. 상단 헤더 없이 우측 상단 고정 닫기, 사이드·본문 중앙 정렬 레이아웃을 적용한다.
|
||||
|
||||
## v1.1.9
|
||||
|
||||
- 관리자 글 목록에 상태·태그·정렬 필터와 댓글 수 표시를 추가.
|
||||
- 글쓰기 사이드바에 추천 글 토글을 추가하고, 홈 Featured와 번개 표시는 실제 추천 글만 기준으로 표시.
|
||||
- 공개 헤더는 텍스트 사이트 이름만 사용하고, 사이드바의 Authors/About 영역은 숨김 처리.
|
||||
|
||||
## v1.1.8
|
||||
|
||||
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.
|
||||
- 태그 추가 버튼을 일반 태그 영역으로 옮기고, 메인 태그 순서는 드래그 후 자동 저장되도록 개선.
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.
|
||||
- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
|
||||
|
||||
## v1.1.5
|
||||
|
||||
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
|
||||
|
||||
## v1.1.4
|
||||
|
||||
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||
- 관리자 계정과 일반 회원 모두 같은 회원 썸네일 저장 규칙(WebP 변환, 1:1 크롭)을 쓰도록 정리.
|
||||
- 태그 목록 카드 그리드 여백 수정 반영.
|
||||
|
||||
## v1.0.19
|
||||
|
||||
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
|
||||
- 기존 공백 2개 hard break도 계속 렌더링되도록 호환 처리.
|
||||
|
||||
## v1.0.18
|
||||
|
||||
- 여러 줄을 비워둔 경우 미리보기와 공개 본문에서도 비운 만큼 공백이 보이도록 보강.
|
||||
- 미리보기 모드에서 편집 툴바와 카드형 패널 외곽을 숨겨 본문만 보이게 정리.
|
||||
- 줄 번호 영역의 스크롤바를 숨겨 작성 화면을 더 차분하게 정리.
|
||||
|
||||
## v1.0.17
|
||||
|
||||
- 글쓰기 영역의 보더와 카드형 배경을 제거해 본문 편집 화면을 더 가볍게 정리.
|
||||
- 줄 번호를 본문 바깥에 띄우고 현재 줄 액센트 배경을 제거.
|
||||
- Enter는 한 줄만 내려가는 새 문단으로, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 조정.
|
||||
- 문단과 제목 아래 기본 간격을 10px 기준으로 정리.
|
||||
|
||||
## v1.0.16
|
||||
|
||||
- 글쓰기에서 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 정리.
|
||||
- 미리보기 전환 후 작성 모드로 돌아오면 기존 커서 위치에서 계속 입력할 수 있도록 개선.
|
||||
- 공개 본문과 관리자 미리보기의 문단 간격을 24px 기준으로 통일.
|
||||
|
||||
## v1.0.15
|
||||
|
||||
- 본문 중간의 빈 줄이 공개 화면과 관리자 미리보기에서 사라지지 않도록 간격 보존을 보강.
|
||||
|
||||
## v1.0.14
|
||||
|
||||
- Markdown-first 전환 후 레거시 블록 본문이나 기존 자동 저장본 때문에 게시물 발행이 막히는 문제를 보강.
|
||||
|
||||
## v1.0.13
|
||||
|
||||
- 관리자 글쓰기에서 외부 웹 글 붙여넣기를 기본 마크다운으로 정리하고, 커서가 위치한 이미지·갤러리 블록을 바로 편집할 수 있도록 개선.
|
||||
|
||||
## v1.0.11
|
||||
|
||||
- 관리자 글쓰기 본문을 Markdown-first 에디터로 교체해 범위 선택, 복사/붙여넣기, 미디어 이미지·갤러리 삽입 흐름을 단순화.
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- Docker 운영 컨테이너가 빌드 시점 설정 대신 `.env.production`의 런타임 환경 변수를 우선 읽도록 보강.
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- owner/admin 계정이 없는 운영 DB에서도 환경 변수 관리자 계정으로 첫 owner를 생성하거나 기존 일반 회원을 승격할 수 있도록 보강.
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- NAS에서 Postgres 초기 마이그레이션 디렉터리 권한 문제로 DB 컨테이너가 재시작될 때 확인할 배포 절차를 정리.
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강.
|
||||
- 배포 문서의 운영 환경 변수 생성 안내를 정리.
|
||||
|
||||
## v1.0.1
|
||||
|
||||
- Docker Compose 네트워크 충돌 대응을 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- 운영 시작 기준 버전.
|
||||
- 운영 환경 DB 설정 누락 시 샘플 콘텐츠 대신 즉시 실패하도록 보강.
|
||||
- 회원 세션 비밀값을 관리자 비밀번호와 분리.
|
||||
- JavaScript 문법 점검과 프로덕션 빌드를 묶은 검증 스크립트 추가.
|
||||
- Nitro 보안 권고 반영 및 취약점 0건 확인.
|
||||
- Docker compose 설정과 앱 이미지 빌드 검증 완료.
|
||||
|
||||
## v0.0.6
|
||||
|
||||
- `.env.example`을 실제 비밀값이 없는 공유 템플릿으로 정리.
|
||||
|
||||
@@ -24,9 +24,12 @@
|
||||
## 스타일
|
||||
|
||||
- TailwindCSS 기본 사용
|
||||
- 다크 인증(`signin`/`signup`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
|
||||
- 다크 인증(`signin`/`signup`/`admin/login`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음). 모바일 세로 flex 그룹 안의 input에는 `flex-1`을 직접 쓰지 않고, 필요한 경우 `sm:flex-1`처럼 가로 배치 이상에서만 적용한다.
|
||||
- 관리자 레이아웃(`admin-layout`)은 공개 사이트 테마와 분리된 라이트 UI로 고정하며, 글쓰기 화면을 제외한 관리자 일반 폼 컨트롤은 `main.css`의 `.admin-layout--light-controls input/textarea/select` 스코프 기준을 따른다.
|
||||
- Tailwind 엔트리는 `nuxt.config.js`의 `tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음).
|
||||
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
|
||||
- 관리자 글 에디터는 Markdown-first textarea 편집을 기준으로 하며 저장 값은 기존 마크다운 문자열을 유지
|
||||
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast`의 `showToast`로 우측 상단(`z-[100]`)에 표시한다.
|
||||
- 관리자 메뉴 관리는 상단/하단 탭으로 나누고, 순서는 드래그로 조정한다(상단은 동일 부모의 형제끼리만).
|
||||
|
||||
```html
|
||||
<main class="site-main w-full max-w-full lg:max-w-[720px]">
|
||||
@@ -53,3 +56,10 @@
|
||||
- 하드코딩 금지
|
||||
- 로컬 개발 설정과 NAS 운영 설정은 별도 환경 파일로 분리
|
||||
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
|
||||
- 운영 환경에서는 `DATABASE_URL`과 `MEMBER_SESSION_SECRET` 누락을 허용하지 않음
|
||||
|
||||
## 검증
|
||||
|
||||
- `npm run lint`: JavaScript 파일 문법 점검
|
||||
- `npm run test`: Nuxt 프로덕션 빌드 기반 회귀 검증
|
||||
- `npm run verify`: 문법 점검과 빌드를 함께 실행
|
||||
|
||||
466
docs/deploy.md
466
docs/deploy.md
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 파일 기준 초안이 있으며 운영 DB 확정 후 NAS에서 검증한다.
|
||||
> 로컬 기준 v1.5.104에서 `npm run lint` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|------|--------|------|
|
||||
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
|
||||
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
|
||||
| 검증 | `npm run verify` | JavaScript 문법 점검 + 프로덕션 빌드 |
|
||||
|
||||
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
|
||||
|
||||
@@ -15,6 +16,174 @@
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### v1.5.104 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차에서 2줄 이상 제목의 활성 표시선이 항목 높이에 맞춰 표시되는지 확인한다.
|
||||
|
||||
### v1.5.103 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차 활성 항목의 왼쪽 브랜드 컬러 막대가 실제 화면에서 표시되는지 확인한다.
|
||||
|
||||
### v1.5.102 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차 항목이 24px 높이에서 세로 중앙 정렬되는지 확인한다.
|
||||
- 활성 목차 항목의 왼쪽 표시선이 브랜드 컬러로 정상 표시되는지 확인한다.
|
||||
|
||||
### v1.5.101 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차 활성 항목이 바뀌어도 항목 텍스트 시작점이 좌우로 흔들리지 않는지 확인한다.
|
||||
|
||||
### v1.5.100 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차에서 각 항목 왼쪽 라인이 이어져 보이고, 활성 항목 라인이 브랜드 컬러와 더 두꺼운 보더로 표시되는지 확인한다.
|
||||
|
||||
### v1.5.99 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 오른쪽 사이드바 목차 라벨이 `목차`로 표시되는지 확인한다.
|
||||
- 활성 목차 항목의 텍스트와 왼쪽 표시선에 브랜드 컬러가 적용되는지 확인한다.
|
||||
|
||||
### v1.5.98 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 오른쪽 사이드바에서 긴 TOC가 별도 내부 스크롤 없이 전체 목록으로 펼쳐지는지 확인한다.
|
||||
- 게시물 사이드 광고가 긴 TOC 아래에 이어서 표시되는지 확인한다.
|
||||
- 본문 스크롤 중 활성 TOC 항목이 오른쪽 사이드바 전체 스크롤 기준으로 따라오는지 확인한다.
|
||||
|
||||
### v1.5.97 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 데스크톱 오른쪽 사이드바에서 블로그 소개·Follow가 숨겨지고 TOC가 최상단에 표시되는지 확인한다.
|
||||
- 게시물 사이드 광고 코드가 있으면 왼쪽 사이드바가 아니라 오른쪽 TOC 아래에 표시되는지 확인한다.
|
||||
- 긴 목차에서 세로 기준선과 활성 항목 표시선이 정상 표시되는지 확인한다.
|
||||
|
||||
### v1.5.93 참고
|
||||
|
||||
- DB 마이그레이션 `056_site_settings_post_tag_limit.sql` 적용이 필요하다.
|
||||
- 관리자 사이트 설정의 POST 설정에서 태그 최대 개수를 1~10개 사이로 저장할 수 있는지 확인한다.
|
||||
- 게시물 작성에서 설정된 개수 이상 태그를 추가할 수 없고, 저장 API도 초과 태그를 거부하는지 확인한다.
|
||||
- 게시물 작성 본문에서 `/표` 또는 `/table`로 기본 표가 삽입되고 공개/미리보기 본문에서 표로 렌더링되는지 확인한다.
|
||||
|
||||
### v1.5.92 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 새 태그 저장 후 `/admin/tags` 목록으로 이동하고 저장 완료 토스트가 표시되는지 확인한다.
|
||||
- 태그 수정 화면에서 변경 전에는 `변경 저장` 버튼이 비활성화되고, 변경·저장 후 다시 비활성화되는지 확인한다.
|
||||
- 게시물 작성에서 `/이미지` 미디어 모달 업로드 후 본문에 자동 삽입되지 않고 라이브러리 목록에서 직접 선택해 삽입할 수 있는지 확인한다.
|
||||
- 미디어 모달 업로드 중 닫기·추가 업로드가 막히는지 확인한다.
|
||||
|
||||
### v1.5.91 참고
|
||||
|
||||
- DB 마이그레이션 `055_add_post_tag_sort_order.sql` 적용이 필요하다.
|
||||
- 게시물 작성 본문에서 `/이미지`로 미디어 선택 모달을 열었을 때 `thumbs/*-card.webp` 카드 썸네일이 보이지 않는지 확인한다.
|
||||
- 미디어 모달 업로드 탭에 파일을 드롭한 뒤 업로드 중 표시가 나오고 추가 드롭·파일 선택이 막히는지 확인한다.
|
||||
- 태그를 여러 개 가진 공개 게시물 목록에서 첫 번째 태그가 고정 표시되고, 관리자 게시물 목록에서는 모든 태그가 표시되는지 확인한다.
|
||||
|
||||
### v1.5.90 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성 라이브 모드에서 코드 블록 아래 일반 문단으로 커서를 이동하면 오른쪽 코드 블록 설정 패널이 닫히는지 확인한다.
|
||||
- 문서 마지막 코드 블록 본문 끝에서 아래 방향키를 눌렀을 때 코드 블록 밖 일반 문단이 생성되고 커서가 이동하는지 확인한다.
|
||||
|
||||
### v1.5.89 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성 소스 모드에서 코드 블록 본문에 `/volume1/...` 같은 경로를 입력해도 슬래시 명령 메뉴가 열리지 않는지 확인한다.
|
||||
- 소스·라이브 모드에서 줄 삭제나 블록 변환 후 `Cmd/Ctrl+Z`로 되돌리고 `Cmd/Ctrl+Shift+Z`로 다시 실행되는지 확인한다.
|
||||
|
||||
### v1.5.88 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성 라이브 모드에서 문서 마지막 인용문 끝에 커서를 두고 아래 방향키를 누르면 인용 밖 일반 문단이 생성되고 커서가 이동하는지 확인한다.
|
||||
|
||||
### v1.5.87 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성 라이브 모드에서 코드 블록에 커서를 두면 코드 설정 패널이 열리고, 일반 문단으로 커서를 옮기면 패널이 닫히는지 확인한다.
|
||||
|
||||
### v1.5.86 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 다크 모드 게시물 본문에서 기본 인용문 텍스트가 밝은 사이트 텍스트 색상으로 표시되는지 확인한다.
|
||||
|
||||
### v1.5.85 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 HTML `<title>`이 `게시물 제목`만 출력하는지 확인한다. 구글 검색 결과 반영은 재크롤링 이후 시간이 걸릴 수 있다.
|
||||
|
||||
### v1.5.84 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 모바일 회원가입 2단계에서 이메일 인증번호 입력창이 다른 입력창과 같은 높이로 표시되는지 확인한다.
|
||||
|
||||
### v1.5.83 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 공개 오른쪽 사이드바에서 사이트 로고가 48px 정사각형으로 유지되는지 확인한다.
|
||||
|
||||
### v1.5.82 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 배포 후 `/sitemap.xml`이 `application/xml`로 응답하고 공개 발행글·고정 페이지·태그 URL을 포함하는지 확인한다.
|
||||
- `noindex` 글, 멤버십·비공개·초안·예약 대기 글이 `/sitemap.xml`에 포함되지 않는지 확인한다.
|
||||
- `/robots.txt`가 `Sitemap: https://.../sitemap.xml` 절대 URL을 포함하는지 확인한다.
|
||||
- Google Search Console에는 `https://도메인/sitemap.xml`을 제출한다. 이후 새 게시물 발행 시 sitemap 파일을 수동 갱신할 필요는 없다.
|
||||
|
||||
### v1.5.81 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성·수정 오른쪽 사이드에 `대표 이미지 표시` 토글이 항상 보이는지 확인한다.
|
||||
- 대표 이미지가 없어도 해당 토글을 켜고 저장할 수 있으며, 이후 대표 이미지를 추가하면 상세 제목 아래 표시 설정이 유지되는지 확인한다.
|
||||
|
||||
### v1.5.80 참고
|
||||
|
||||
- DB 마이그레이션 `054_add_post_show_featured_image.sql` 적용 필요.
|
||||
- 새 게시물 본문 이미지를 업로드해도 `/public/uploads/posts/YYYY/MM/thumbs/*-card.webp`가 바로 생성되지 않는지 확인한다.
|
||||
- 게시물 대표 이미지를 설정하고 저장하면 해당 원본의 카드 썸네일이 생성되는지 확인한다.
|
||||
- 대표 이미지 원본에 카드 썸네일이 없으면 관리자 미디어 상세에서 `카드 썸네일 생성` 버튼으로 다시 만들 수 있는지 확인한다.
|
||||
- 게시물 상세 제목 아래 대표 이미지는 글쓰기 화면의 `본문 상단 대표 이미지` 옵션이 켜진 글에서만 표시되는지 확인한다.
|
||||
- 운영 기존 업로드 중 대표 이미지 fallback 상태가 보이면 `npm run images:backfill-post-thumbnails`를 실행한다. 이 명령은 게시물 대표 이미지 URL만 대상으로 한다.
|
||||
|
||||
### v1.5.79 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 미디어 화면에서 일반 미디어 라이브러리에 `thumbs/*-card.webp` 파일이 섞이지 않고, 카드 썸네일 탭에만 표시되는지 확인한다.
|
||||
- 대표 이미지로 사용 중인 원본에 카드 썸네일이 있으면 카드 썸네일 탭에서 사용 중으로 표시되고 삭제가 차단되는지 확인한다.
|
||||
- 대표 이미지로 사용 중인 원본에 카드 썸네일이 없으면 원본 항목에 `원본` 배지와 fallback 상태가 표시되는지 확인한다.
|
||||
- 운영 기존 업로드 중 fallback 상태가 보이면 `npm run images:backfill-post-thumbnails`를 실행한다.
|
||||
|
||||
### v1.5.78 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 새 게시물 이미지 업로드 시 `/public/uploads/posts/YYYY/MM/thumbs/*-card.webp` 카드 썸네일이 함께 생성되는지 확인한다.
|
||||
- 기존 업로드 이미지가 많은 운영 환경에서는 배포 후 `npm run images:backfill-post-thumbnails`를 한 번 실행해 누락된 카드 썸네일을 생성한다.
|
||||
- 메인 Featured·Latest, 게시물 목록, 태그 목록에서 생성된 썸네일 URL을 우선 요청하고, 썸네일이 없는 외부/기존 이미지는 원본으로 대체되는지 확인한다.
|
||||
|
||||
### v1.5.77 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 메인 화면 Latest compact/list 목록에서 긴 요약이 최대 2줄로 표시되고 발행일·댓글 메타가 잘리지 않는지 확인한다.
|
||||
- 모바일 메인 화면 Latest compact 목록에서 썸네일이 80px 정사각형으로 표시되고, `sm` 이상에서는 기존 비율형 썸네일이 유지되는지 확인한다.
|
||||
- 대표 이미지 없는 placeholder 썸네일의 긴 제목이 모바일에서 썸네일 밖으로 넘치지 않는지 확인한다.
|
||||
|
||||
### v1.5.75 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성·수정 화면 오른쪽 설정 패널 하단에 본문 통계가 표시되는지 확인한다.
|
||||
- 본문 입력 시 단어 수, 공백 제외 문자 수, 공백 수, 읽기 시간, 블록 수, 이미지 수가 갱신되는지 확인한다.
|
||||
|
||||
### v1.5.74 참고
|
||||
|
||||
- DB 마이그레이션 `053_site_settings_post_sidebar_ad.sql` 적용 필요.
|
||||
- 사이트 설정 Ads에서 게시물 사이드 광고 코드를 저장한 뒤 게시물 상세 데스크톱 오른쪽 사이드바 TOC 아래에 표시되는지 확인한다.
|
||||
- 게시물 상세 오른쪽 사이드바에서는 일반 오른쪽 사이드 광고가 아니라 게시물 사이드 광고가 표시되는지 확인한다.
|
||||
- 긴 게시물에서 인아티클 광고가 본문 길이에 따라 0~2회로 제한되는지 확인한다.
|
||||
|
||||
### 필수 조건
|
||||
|
||||
- Node.js 22 LTS 권장
|
||||
@@ -44,6 +213,28 @@ openssl rand -hex 32
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### v1.5.55 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 소스 모드 textarea에서 `Cmd+Shift+K`로 현재 줄이 삭제되는지 확인한다.
|
||||
- 소스 모드 textarea에서 여러 줄 선택 후 `Cmd+Shift+K`로 선택 범위 줄들이 삭제되는지 확인한다.
|
||||
- 라이브 모드에서 preview 루트 또는 카드형 블록에 포커스된 상태에서도 `Cmd+Shift+K`로 현재 줄 또는 블록이 삭제되는지 확인한다.
|
||||
|
||||
### v1.5.54 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- `/콜아웃` 삽입 시 기본 선언부가 `emoji=none`으로 들어가고 아이콘이 표시되지 않는지 확인한다.
|
||||
- 오른쪽 블록 설정 패널에서 콜아웃 제목을 입력하면 선언부 `title` 옵션과 라이브·공개 렌더링에 반영되는지 확인한다.
|
||||
- 콜아웃 아이콘 또는 제목이 있을 때 헤더가 왼쪽 상단에 표시되고 본문은 아래 줄에서 시작하는지 확인한다.
|
||||
|
||||
### v1.5.53 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 라이브 콜아웃 본문 5줄 이상에서 `Shift+방향키` 범위 선택이 여러 줄에 걸쳐 유지되는지 확인한다.
|
||||
- 라이브 콜아웃 선택 범위를 삭제하거나 붙여넣을 때 콜아웃 본문 줄만 갱신되는지 확인한다.
|
||||
- 콜아웃 아이콘 사용 시 라이브·사용자 화면 모두 왼쪽 상단에 아이콘이 붙는지 확인한다.
|
||||
- 콜아웃 아이콘 미사용 시 라이브 편집 화면과 사용자 화면 모두 아이콘 자리 표시자가 남지 않는지 확인한다.
|
||||
|
||||
### 로컬 개발 DB
|
||||
|
||||
로컬 개발 DB는 Docker Compose의 `sori-studio-db` 서비스만 실행한다.
|
||||
@@ -67,6 +258,111 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
|
||||
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
|
||||
```
|
||||
|
||||
### v1.5.52 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 연속 콜아웃을 만들고 위 콜아웃에서 한글 입력 후 Enter 시 아래 콜아웃 선언 줄이 유지되는지 확인한다.
|
||||
- 라이브 콜아웃 안에서 한글 입력 후 Enter 시 본문 줄이 1줄만 추가되는지 확인한다.
|
||||
- 갤러리·토글·임베드 등 다른 `:::` fenced 블록 편집 시 다음 블록 첫 줄이 교체 범위에 포함되지 않는지 확인한다.
|
||||
|
||||
### v1.5.51 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 라이브 인용 안에서 한글 입력 후 Enter 시 인용 줄이 1줄만 추가되는지 확인한다.
|
||||
- 라이브 콜아웃 안에서 한글 입력 후 Enter 시 콜아웃 본문 줄이 1줄만 추가되는지 확인한다.
|
||||
- 라이브 콜아웃 마지막 줄에서 아래 방향키 입력 시 새 본문 줄이 생성되지 않는지 확인한다.
|
||||
- 라이브 인용 마지막 줄에서 아래 방향키 입력 시 외부 빈 문단이 생성되고 커서가 이동하는지 확인한다.
|
||||
|
||||
### v1.5.50 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 라이브 모드에서 한글 `> 텍스트` 입력 후 Enter 시 다음 인용 줄이 생성되고 커서가 내부에 있는지 확인한다.
|
||||
- 라이브 인용 마지막 줄에서 아래 방향키 입력 시 외부 빈 문단이 생성되고 커서가 이동하는지 확인한다.
|
||||
- 라이브 콜아웃 본문 여러 줄에서 2번째·3번째 줄 `Cmd+Shift+K`가 해당 줄만 삭제하는지 확인한다.
|
||||
- 라이브 콜아웃에서 한글 입력 후 Enter 시 본문 줄이 1줄만 추가되는지 확인한다.
|
||||
- `/콜아웃` Enter 생성 후 콜아웃 본문에 마지막 한글 조합 글자가 남지 않는지 확인한다.
|
||||
|
||||
### v1.5.49 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 글쓰기 라이브 모드에서 코드·콜아웃·토글 내부 커서 위치별 `Cmd+Shift+K` 줄 삭제를 확인한다.
|
||||
- 코드·콜아웃·토글 본문 마지막 1줄에서 `Cmd+Shift+K` 입력 시 블록 전체가 삭제되는지 확인한다.
|
||||
- 라이브 콜아웃 마지막 줄에서 아래 방향키로 다음 블록으로 빠져나가는지 확인한다.
|
||||
- `/콜아웃` Enter 생성 후 콜아웃 본문에 마지막 조합 글자가 남지 않는지 확인한다.
|
||||
|
||||
### v1.5.48 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 게시물 작성 상단 왼쪽 상태 표시가 텍스트만 표시하고, 외부 이동 아이콘이나 링크 동작이 없는지 확인한다.
|
||||
- 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서 확인한다.
|
||||
|
||||
### v1.5.47 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 대표 이미지가 있는 공개 게시물이 RSS item에 `media:thumbnail`과 `media:content`를 포함하는지 확인한다.
|
||||
- 상대 이미지 URL이 RSS에서 절대 URL로 변환되는지 확인한다.
|
||||
|
||||
### v1.5.46 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 글쓰기 라이브 모드에서 위에서 아래 방향키로 콜아웃·인용 블록에 진입되는지 확인한다.
|
||||
- 콜아웃·인용 배경 프리셋에 분홍이 보이지 않고 같은 6색 팔레트를 쓰는지 확인한다.
|
||||
- 작은 화면에서 게시물 설정 패널을 열어도 본문 폭이 압축되지 않는지 확인한다.
|
||||
|
||||
### v1.5.45 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 글쓰기에서 라이브 모드 콜아웃·인용 블록 포커스 시 오른쪽 블록 설정 패널이 열리는지 확인한다.
|
||||
- 인용 블록 기본 배경이 회색이고 분홍 옵션이 노출되지 않는지 확인한다.
|
||||
|
||||
### v1.5.44 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 사이트 설정의 블로그 제목·설명, 사이트 정보, 사이트 코드 읽기 화면만 레이아웃 변경되었다.
|
||||
|
||||
### v1.5.43 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 배포 후 `/rss.xml`, `/feed.xml`, `/rss`가 `application/rss+xml`로 응답하고 최근 공개 발행글을 포함하는지 확인한다.
|
||||
- 관리자 SNS 정보에서 RSS 프리셋을 사용할 경우 주소는 `/rss.xml`을 권장한다.
|
||||
|
||||
### v1.5.42 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 공개 오른쪽 사이드바 FOLLOW 영역의 직접 SVG 아이콘 정렬만 수정한다.
|
||||
|
||||
### v1.5.41 마이그레이션
|
||||
|
||||
- `049_fix_social_links_jsonb_string.sql`: 기존에 JSONB 문자열로 잘못 저장된 `site_settings.social_links` 값을 JSONB 배열로 복구한다.
|
||||
- 적용 후 관리자 사이트 설정의 SNS 정보 저장값이 읽기 모드에서 유지되는지 확인한다.
|
||||
|
||||
### v1.5.40 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 사이트 설정의 SNS 정보에서 프리셋이 없는 서비스는 `직접 SVG`를 선택해 SVG 아이콘과 주소를 함께 저장한다.
|
||||
- `https://`를 생략한 SNS 주소는 저장 시 자동 보정된다.
|
||||
|
||||
### v1.5.39 마이그레이션
|
||||
|
||||
- `048_site_settings_social_links.sql`: `site_settings`에 `social_links` JSONB 컬럼을 추가한다.
|
||||
- 적용 후 관리자 사이트 설정의 SNS 정보와 공개 오른쪽 사이드바 FOLLOW 노출이 정상 동작하는지 확인한다.
|
||||
|
||||
### v1.5.38 마이그레이션
|
||||
|
||||
- `047_site_settings_announcement_alignment.sql`: `site_settings`에 `announcement_alignment` 컬럼을 추가한다.
|
||||
- 적용 후 관리자 사이트 설정의 어나운스 바 정렬(중앙/왼쪽)이 공개 화면에 반영되는지 확인한다.
|
||||
|
||||
### v1.5.35 마이그레이션
|
||||
|
||||
- `045_analytics_traffic_sources.sql`: 방문자 유입원·디바이스·검색 키워드 일별 축약 집계 테이블과 중복 방문 제거용 컬럼을 추가한다.
|
||||
- 적용 이후부터 수집되는 페이지뷰에 대해서만 유입 정보가 쌓인다. 과거 방문 데이터는 소급 집계하지 않는다.
|
||||
- 검색 키워드는 검색엔진이 referrer query를 전달한 경우에만 표시된다.
|
||||
|
||||
### v1.5.34 마이그레이션
|
||||
|
||||
- `044_site_settings_custom_code.sql`: `site_settings`에 `ads_txt`, `custom_head_code`, `custom_footer_code` 컬럼을 추가한다.
|
||||
- 배포 후 `/ads.txt`와 공개 페이지 HTML head/body 하단 코드 삽입이 정상 동작하는지 확인한다.
|
||||
|
||||
### 확인 주소
|
||||
|
||||
- 개발 서버: http://127.0.0.1:43117
|
||||
@@ -116,7 +412,7 @@ ssh [NAS_IP]
|
||||
|
||||
```bash
|
||||
# 프로젝트 디렉토리로 이동
|
||||
cd /volume1/docker/sori.studio
|
||||
cd /volume1/docker/projects/apps/
|
||||
|
||||
# 프로젝트 클론
|
||||
git clone https://git.sori.studio/zenn/sori.studio.git
|
||||
@@ -128,21 +424,70 @@ cd sori.studio
|
||||
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
||||
cp .env.example .env.production
|
||||
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
||||
# 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨
|
||||
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
||||
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
|
||||
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
||||
|
||||
# Docker 빌드 및 실행
|
||||
docker compose --env-file .env.production up -d --build
|
||||
|
||||
# 운영 DB 마이그레이션 상태 확인
|
||||
sh scripts/migrate-production-db.sh status
|
||||
|
||||
# schema_migrations 도입 전 운영 DB가 이미 최신이면 최초 1회 기준점 기록(실제 SQL 실행 없음)
|
||||
sh scripts/migrate-production-db.sh baseline
|
||||
|
||||
# 이후 배포에서는 아직 적용되지 않은 SQL만 순서대로 실행
|
||||
sh scripts/migrate-production-db.sh migrate
|
||||
```
|
||||
|
||||
### 운영 업데이트 (코드 반영)
|
||||
|
||||
이미 한 번 올려 둔 NAS에서 **새 커밋을 받아 반영**할 때는 보통 아래 순서를 따른다. 최초 설치 절차와 달리 `git clone`은 하지 않는다.
|
||||
|
||||
```bash
|
||||
# 프로젝트 루트로 이동 (경로는 NAS 환경에 맞게 조정)
|
||||
cd /volume1/docker/projects/apps/sori.studio
|
||||
|
||||
# 원격 저장소 최신 코드 받기
|
||||
git pull
|
||||
|
||||
# DB 스키마 변경이 포함된 배포면 미적용 SQL만 적용 (npm 없이 실행 가능)
|
||||
sh scripts/migrate-production-db.sh status
|
||||
sh scripts/migrate-production-db.sh migrate
|
||||
|
||||
# 앱 이미지 재빌드 후 컨테이너 재기동
|
||||
docker compose --env-file .env.production up -d --build
|
||||
```
|
||||
|
||||
| 단계 | 설명 |
|
||||
|------|------|
|
||||
| `git pull` | 애플리케이션·Dockerfile·`db/migrations` 등 Git에 있는 변경을 받는다. |
|
||||
| `migrate` | `db/migrations/`에 새 SQL이 있으면 운영 DB에만 적용한다. 스키마 변경이 없으면 생략해도 된다. |
|
||||
| `up -d --build` | Nuxt 프로덕션 빌드가 Docker 이미지 안에서 수행되므로, **NAS 호스트에 Node/npm이 없어도** 앱 코드 반영이 가능하다. |
|
||||
|
||||
주의:
|
||||
|
||||
- `.env.production`은 Git에 포함하지 않는다. `git pull`로 덮어쓰이지 않는다. 값을 바꿀 때만 파일을 직접 수정한다.
|
||||
- `public/uploads/` 업로드 파일은 Docker 볼륨(`./public/uploads`)에 있으므로, **이미지 파일만 추가·수정한 경우** 앱 재빌드 없이도 URL로 바로 보인다.
|
||||
- 로컬에서 미리 확인하려면 `npm run verify` 후 NAS에서 위 명령을 실행하면 된다.
|
||||
|
||||
컨테이너만 재시작하고 이미지는 그대로 두려면(환경 변수만 바꾼 경우 등):
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production up -d
|
||||
```
|
||||
|
||||
### 프로덕션 빌드 (NAS에서)
|
||||
코드 변경 없이 `.env.production`만 수정했다면 `--build` 없이 `up -d`만으로 충분하다.
|
||||
|
||||
### Docker 네트워크 충돌 대응
|
||||
|
||||
NAS에 Docker 컨테이너가 많이 실행 중이면 `could not find an available, non-overlapping IPv4 address pool` 오류가 날 수 있다. 이 프로젝트는 기본 `DOCKER_SUBNET=10.250.50.0/24`를 사용한다. 해당 대역도 NAS 내부망 또는 다른 Docker 네트워크와 겹치면 `.env.production`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다.
|
||||
|
||||
```bash
|
||||
# 프로덕션 빌드
|
||||
npm run build
|
||||
|
||||
# 또는 Docker로 빌드
|
||||
docker build -t sori.studio:latest .
|
||||
docker run -d -p 3000:3000 sori.studio:latest
|
||||
DOCKER_SUBNET=10.250.51.0/24
|
||||
docker compose --env-file .env.production up -d --build
|
||||
```
|
||||
|
||||
### 포트
|
||||
@@ -151,6 +496,7 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
- NAS Docker 외부: 43118
|
||||
- 컨테이너 내부: 3000
|
||||
- PostgreSQL 외부: 43119
|
||||
- Docker 내부 네트워크 기본값: `10.250.50.0/24`
|
||||
- HTTPS: 3001 (SSL 설정 시)
|
||||
|
||||
---
|
||||
@@ -163,12 +509,52 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
||||
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
||||
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||
- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB에 owner/admin이 없는 최초 관리자 생성에만 사용한다. 같은 이메일의 일반 회원이 이미 있으면 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준으로 갱신한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다.
|
||||
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
|
||||
- Docker 운영 컨테이너는 `.env.production`의 서버 환경 변수를 런타임 `process.env`에서 우선 읽는다.
|
||||
|
||||
### 이메일 인증(Resend, 선택)
|
||||
|
||||
회원가입(일반)·비밀번호 찾기에 이메일 OTP를 쓰려면 `npm run db:migrate:dev`로 `018_email_otp_challenges.sql`을 적용하고, `.env`에 다음을 설정한다.
|
||||
|
||||
| 변수 | 설명 |
|
||||
|------|------|
|
||||
| `RESEND_API_KEY` | [Resend](https://resend.com) API 키 |
|
||||
| `RESEND_FROM_EMAIL` | 발신 주소(Resend에서 허용된 도메인 또는 테스트 발신자) |
|
||||
| `MEMBER_SESSION_SECRET` | 회원 세션 쿠키 서명용 비밀값. 운영에서는 필수이며 `ADMIN_PASSWORD`와 분리된 긴 난수 문자열을 사용한다. **OTP 해시에 쓰는 pepper로도 사용**되므로, `EMAIL_OTP_PEPPER`를 비워 두면 이 값이 OTP용 비밀 재료가 된다. |
|
||||
| `EMAIL_OTP_PEPPER` | **선택.** 이메일로 받은 6자리 숫자를 DB에 넣기 전 SHA256 해시할 때 섞는 **서버 전용 비밀 문자열**이다. DB가 유출돼도 pepper를 모르면 인증번호 역산·무차별 대입이 어렵다. **짧은 숫자 한두 개가 아니라**, `openssl rand -hex 32`처럼 **긴 난수 문자열(32바이트 이상 권장)**을 쓰는 것이 안전하다. 비우면 `MEMBER_SESSION_SECRET`을 pepper로 쓴다. |
|
||||
|
||||
`RESEND_API_KEY`와 `RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다.
|
||||
|
||||
### 게시물 Export 설정(선택)
|
||||
|
||||
| 변수 | 설명 |
|
||||
|------|------|
|
||||
| `POST_EXPORT_MAX_FILE_SIZE_BYTES` | 게시물 Export 분할 ZIP 목표 최대 용량. 기본값은 500MB이며 관리자 설정 화면 요청값이 있으면 그 값을 우선 사용한다. |
|
||||
|
||||
- 게시물 Import는 관리자 설정의 Import 패널에서 Export ZIP 파일을 업로드해 실행한다. 1회 업로드 파일은 300MB 이하, Markdown 게시물은 최대 1000개까지 처리한다.
|
||||
|
||||
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
||||
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
||||
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
||||
- NAS 운영 DB 마이그레이션은 NAS 호스트에 npm이 없어도 실행할 수 있도록 `sh scripts/migrate-production-db.sh status`로 적용 상태를 확인하고, `sh scripts/migrate-production-db.sh migrate`로 미적용 파일만 실행한다.
|
||||
- 운영 환경 파일은 프로젝트 루트의 `.env.production`을 우선 사용한다. 없으면 `.env`를 읽고, 둘 다 없으면 실행 중인 `sori-studio-db` 컨테이너의 `POSTGRES_DB`·`POSTGRES_USER`를 사용한다.
|
||||
- `schema_migrations`가 없는 기존 운영 DB에서 `posts` 테이블이 감지되면 `migrate`는 001부터 자동 실행하지 않는다. 현재 코드 기준 최신 DB라면 최초 1회 `sh scripts/migrate-production-db.sh baseline`으로 기존 파일을 적용 완료로 기록한다. 특정 번호까지만 기록하려면 예: `sh scripts/migrate-production-db.sh baseline 031`.
|
||||
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
|
||||
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
|
||||
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
|
||||
|
||||
### 통계 데이터 보관 정책
|
||||
|
||||
- `site_analytics_daily`, `post_analytics_daily`: 사이트 전체 방문자와 게시물별 조회수의 누적 원본이므로 자동 삭제하지 않는다.
|
||||
- `analytics_traffic_daily`: 유입원·디바이스·키워드 축약 집계 원본이므로 자동 삭제하지 않는다.
|
||||
- `analytics_daily_visitors`: 일별 중복 방문 제거용 해시만 담으므로 32일 초과 행은 통계 수집·관리자 조회 흐름에서 주기적으로 삭제한다.
|
||||
- `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다.
|
||||
- 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다.
|
||||
|
||||
### 개발/운영 DB 분리 검증 절차
|
||||
|
||||
@@ -202,6 +588,7 @@ test -f .env.production && rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|APP_P
|
||||
- NAS Docker 내부 실행 기준이면 `DATABASE_URL` 호스트가 `sori-studio-db`
|
||||
- NAS 외부 DB를 별도 인스턴스로 쓰는 경우에도 로컬 개발 DB(`127.0.0.1:43119`)를 가리키지 않음
|
||||
- `APP_PORT=43118`
|
||||
- `MEMBER_SESSION_SECRET`이 비어 있지 않고 `ADMIN_PASSWORD`와 다름
|
||||
- `.env.development`와 DB 비밀번호, 관리자 비밀번호가 서로 다름
|
||||
|
||||
3. 로컬 개발 DB 연결 확인.
|
||||
@@ -240,13 +627,70 @@ git diff -- . ':!package-lock.json'
|
||||
- `.env.development`, `.env.production`이 변경 목록에 포함되지 않음
|
||||
- 문서와 코드 diff에 실제 DB 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음
|
||||
|
||||
### 컨테이너가 `Restarting`일 때
|
||||
|
||||
`Error response from daemon: Container … is restarting, wait until the container is running`은 **프로세스가 곧바로 종료**되어 `restart: unless-stopped`가 반복 시도하는 상태다. 원인은 로그에 나온다.
|
||||
|
||||
1. **어느 서비스인지 확인** (`docker-compose.yml` 기준 이름은 `sori-studio`, `sori-studio-db`).
|
||||
|
||||
```bash
|
||||
docker ps -a --filter "name=sori-studio"
|
||||
```
|
||||
|
||||
2. **해당 컨테이너 로그** (가장 중요).
|
||||
|
||||
```bash
|
||||
docker logs sori-studio --tail 150
|
||||
docker logs sori-studio-db --tail 150
|
||||
```
|
||||
|
||||
Compose로 올렸다면:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production logs sori-studio --tail 200
|
||||
docker compose --env-file .env.production logs sori-studio-db --tail 200
|
||||
```
|
||||
|
||||
3. **자주 나오는 원인**
|
||||
|
||||
- **`sori-studio`**: `DATABASE_URL` 누락·오타, `MEMBER_SESSION_SECRET` 미설정, DB 호스트가 컨테이너 기준으로 잘못됨(예: 앱은 Docker 안인데 URL만 `127.0.0.1`로 DB를 가리킴), 애플리케이션 예외로 즉시 종료.
|
||||
- **`sori-studio-db`**: 이미 초기화된 볼륨과 다른 `POSTGRES_PASSWORD`로 다시 올린 경우, `docker-entrypoint-initdb.d` 마이그레이션 SQL 오류, 디스크/권한 문제.
|
||||
- **`sori-studio-db` 로그에 `ls: can't open '/docker-entrypoint-initdb.d/': Permission denied`**: 아래 **NAS·호스트에서 `db/migrations` 권한** 절차를 확인한다.
|
||||
|
||||
4. 로그를 고친 뒤에는 `docker compose --env-file .env.production up -d`로 다시 올리고, `docker ps`에서 `Up` 상태인지 확인한다.
|
||||
|
||||
### NAS·호스트에서 `db/migrations` 권한
|
||||
|
||||
`docker-compose.yml`은 `./db/migrations`를 Postgres 이미지의 `/docker-entrypoint-initdb.d`에 **읽기 전용**으로 붙인다. 공식 엔트리포인트는 이 디렉터리를 `ls`로 읽는데, NAS(UGREEN 등)나 SSH로 복사한 트리에서 **폴더·파일이 700/600만 허용**이거나 **상위 디렉터리에 실행(x) 비트가 없으면** 컨테이너 안 `postgres` 사용자가 경로를 통과하지 못해 `Permission denied`가 반복되고 DB 컨테이너가 재시작 루프에 들어갈 수 있다.
|
||||
|
||||
프로젝트 루트( `docker compose` 를 실행하는 디렉터리)에서 SSH로 다음을 적용한다. **비밀번호는 바꾸지 않으며**, 읽기·디렉터리 통과만 연다.
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/projects/apps/sori.studio
|
||||
# 마이그레이션 디렉터리와 그 안 SQL: 모두 읽기, 디렉터리는 검색 가능
|
||||
sudo chmod -R a+rX db/migrations
|
||||
# 상위 db/, 프로젝트 루트가 다른 사용자만 rwx 인 경우 통과 허용
|
||||
sudo chmod a+x . db db/migrations
|
||||
```
|
||||
|
||||
그다음 DB 컨테이너만 재시작한다.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production restart sori-studio-db
|
||||
```
|
||||
|
||||
여전히 동일하면 프로젝트가 **SMB 공유 폴더 위**에만 있지 않은지 확인한다. Docker 데몬이 네이티브 경로(ext4 등)의 디렉터리를 마운트할 때 권한이 더 예측 가능하다.
|
||||
|
||||
## 업로드 파일
|
||||
|
||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||
- 게시물 Export ZIP 산출물은 `public/uploads/exports/YYYY/MM/{jobId}/` 아래 생성되며, 관리자 다운로드 API를 통해 내려받는다.
|
||||
- 완료·실패한 게시물 Export 작업을 관리자 화면에서 삭제하면 연결된 ZIP 파일도 함께 삭제된다.
|
||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||
- NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
|
||||
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
|
||||
- `MAX_FILE_SIZE`, `MAX_VIDEO_FILE_SIZE`, `MAX_AUDIO_FILE_SIZE`, `MAX_DOCUMENT_FILE_SIZE` 환경 변수로 관리자 미디어 업로드 최대 크기를 제한한다. 리버스 프록시(Nginx 등)를 쓰면 `client_max_body_size`가 앱 한도보다 작지 않은지 함께 확인한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
## 사용자 액션 필요 항목
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user