Compare commits
108 Commits
v1.1.0_글쓰기
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 28d95129c2 | |||
| 60ca6e0930 | |||
| ccb6db5f89 | |||
| 629ef8c4c6 | |||
| cc9e5949fa | |||
| 5c93643949 | |||
| 9a4820e69c | |||
| 928b8446b4 | |||
| 4c0875446b | |||
| 16a12d304d | |||
| 6daf9ca15e | |||
| 56a2c23471 | |||
| 09b6c51048 | |||
| 264f551cb4 | |||
| 94226423c7 | |||
| 67fbba3814 | |||
| f048eaac2b | |||
| 648ce5fbab | |||
| 35b9893eab | |||
| 675e6bca78 | |||
| 83f66a4b93 | |||
| accd933e99 | |||
| 24611af8b6 | |||
| b38fc9f154 | |||
| 2cb1ff4651 | |||
| b5970c8ada | |||
| 79009b21e6 | |||
| 21d01632be | |||
| b3c7f26d10 | |||
| 4da1ade2cf | |||
| e3b8087b09 | |||
| ba17e3aa18 | |||
| 093d09c8bf | |||
| 1bcd2f6898 | |||
| 5b78a8c92f | |||
| 600b0fd1d9 | |||
| 1a670e237f | |||
| 0e2b701862 | |||
| e2df9d55ac | |||
| c2e69d9048 | |||
| 9d91355c81 | |||
| ef1a9d9032 | |||
| 5732a27498 | |||
| 212bd3f34f | |||
| 5735fd5046 | |||
| a4c1b42369 | |||
| f8621d49d8 | |||
| 7c8245c4e9 | |||
| 11203ba251 | |||
| abce690546 | |||
| 052ce316df | |||
| 71046ed883 | |||
| dc50780ff8 | |||
| edbbd3c83c | |||
| ac57ff458d | |||
| cb92b32f9c | |||
| 602106ac9d | |||
| 7f017a03a5 | |||
| 8ca63c0d00 | |||
| fd9416c0e4 | |||
| d7a3149ea1 | |||
| e78e09f3fd | |||
| a5ae2c3fce | |||
| 3843e16d9f | |||
| 6333c4254f | |||
| b989193dab | |||
| 62ceaa3591 | |||
| a25306389b | |||
| 0ad2ab3f9d | |||
| 6536465b12 | |||
| dcd1060ec7 | |||
| 38ca3a4709 | |||
| 8f53210756 | |||
| 9e5728074a | |||
| 10c5a099fc | |||
| 6919669330 | |||
| 095a8fa5f0 | |||
| f8e04003fd | |||
| a396d1d022 | |||
| cc34db40f2 | |||
| 0e70d4482d | |||
| c43873ce5f | |||
| abb77dbb4d | |||
| 3623305119 | |||
| b6a3228b09 | |||
| b77f37a94e | |||
| 02d33996c5 | |||
| 797a6dd5a0 | |||
| 3fb8a40031 | |||
| 666bd304fc | |||
| 0c051cbe3b | |||
| c9b484e4c8 | |||
| a867269d9b | |||
| c474a8b9a3 | |||
| 47620ab24c | |||
| 14ce897bf8 | |||
| 6fd61911fd | |||
| 2074b0b93a | |||
| ca1e17890b | |||
| 2768975752 | |||
| 59a50a0c97 | |||
| b4e4e37f5a | |||
| 536ee7079e | |||
| 9e544d97fa | |||
| 20b901d4a1 | |||
| 0ed848a2eb | |||
| 08f0aa0efa | |||
| 17dcd04339 |
@@ -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
|
||||
|
||||
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>
|
||||
20
app.vue
20
app.vue
@@ -1,11 +1,21 @@
|
||||
<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: ''
|
||||
faviconUrl: '',
|
||||
logoUrl: '',
|
||||
logoText: '井',
|
||||
brandColor: DEFAULT_BRAND_COLOR
|
||||
})
|
||||
})
|
||||
|
||||
const siteAccentStyle = computed(() => ({
|
||||
'--site-accent': normalizeBrandColor(appSiteSettings.value?.brandColor || DEFAULT_BRAND_COLOR)
|
||||
}))
|
||||
|
||||
useHead(() => ({
|
||||
titleTemplate: (titleChunk) => titleChunk
|
||||
? `${titleChunk} · ${appSiteSettings.value.title}`
|
||||
@@ -23,7 +33,9 @@ useHead(() => ({
|
||||
</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: '게시물 상세 화면의 왼쪽 사이드바 하단에 표시됩니다.',
|
||||
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>
|
||||
@@ -49,10 +49,42 @@ const calloutBackgroundOptions = [
|
||||
{ value: 'green', label: '초록' },
|
||||
{ value: 'yellow', label: '노랑' },
|
||||
{ value: 'red', label: '빨강' },
|
||||
{ value: 'purple', label: '보라' },
|
||||
{ value: 'pink', label: '핑크' }
|
||||
{ value: 'purple', label: '보라' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 콜아웃 배경 스와치 색상을 반환한다.
|
||||
* @param {string} background - 배경 키
|
||||
* @returns {string} CSS 배경색
|
||||
*/
|
||||
const getCalloutBackgroundSwatch = (background) => {
|
||||
if (background === 'gray') {
|
||||
return 'color-mix(in srgb, #050505 10%, #ffffff)'
|
||||
}
|
||||
|
||||
if (background === 'blue') {
|
||||
return 'color-mix(in srgb, #0055ff 10%, #ffffff)'
|
||||
}
|
||||
|
||||
if (background === 'green') {
|
||||
return 'color-mix(in srgb, #16ae68 10%, #ffffff)'
|
||||
}
|
||||
|
||||
if (background === 'yellow') {
|
||||
return 'color-mix(in srgb, #ffff00 10%, #ffffff)'
|
||||
}
|
||||
|
||||
if (background === 'red') {
|
||||
return 'color-mix(in srgb, #ff0000 10%, #ffffff)'
|
||||
}
|
||||
|
||||
if (background === 'purple') {
|
||||
return 'color-mix(in srgb, #8800ff 10%, #ffffff)'
|
||||
}
|
||||
|
||||
return 'color-mix(in srgb, #0055ff 10%, #ffffff)'
|
||||
}
|
||||
|
||||
const blockCommands = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
@@ -151,7 +183,7 @@ const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '',
|
||||
title: options.title || '',
|
||||
width: options.width || 'regular',
|
||||
images: options.images || [],
|
||||
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
|
||||
calloutEmojiEnabled: options.calloutEmojiEnabled === true,
|
||||
calloutEmoji: options.calloutEmoji || '💡',
|
||||
calloutBackground: options.calloutBackground || 'blue'
|
||||
})
|
||||
@@ -163,7 +195,7 @@ const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '',
|
||||
*/
|
||||
const parseCalloutOptions = (line) => {
|
||||
const options = {
|
||||
calloutEmojiEnabled: true,
|
||||
calloutEmojiEnabled: false,
|
||||
calloutEmoji: '💡',
|
||||
calloutBackground: 'blue'
|
||||
}
|
||||
@@ -421,7 +453,7 @@ const serializeBlocks = () => {
|
||||
|
||||
if (block.type === 'embed') {
|
||||
return block.url.trim()
|
||||
? { type: block.type, value: `:::embed\n${block.url.trim()}\n:::` }
|
||||
? { type: block.type, value: block.url.trim() }
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -2170,15 +2202,7 @@ defineExpose({
|
||||
></span>
|
||||
<span
|
||||
class="relative block size-full rounded-full border-2 border-white ring-1 ring-black/10"
|
||||
:style="{
|
||||
background: block.calloutBackground === 'gray' ? 'rgba(100,116,139,0.28)'
|
||||
: block.calloutBackground === 'blue' ? 'rgba(59,130,246,0.3)'
|
||||
: block.calloutBackground === 'green' ? 'rgba(34,197,94,0.3)'
|
||||
: block.calloutBackground === 'yellow' ? 'rgba(245,158,11,0.34)'
|
||||
: block.calloutBackground === 'red' ? 'rgba(239,68,68,0.3)'
|
||||
: block.calloutBackground === 'purple' ? 'rgba(168,85,247,0.3)'
|
||||
: 'rgba(236,72,153,0.3)'
|
||||
}"
|
||||
:style="{ background: getCalloutBackgroundSwatch(block.calloutBackground) }"
|
||||
></span>
|
||||
</button>
|
||||
|
||||
@@ -2197,15 +2221,7 @@ defineExpose({
|
||||
>
|
||||
<span
|
||||
class="size-[1.4rem] rounded-full border border-black/10"
|
||||
:style="{
|
||||
background: backgroundOption.value === 'gray' ? 'rgba(100,116,139,0.28)'
|
||||
: backgroundOption.value === 'blue' ? 'rgba(59,130,246,0.3)'
|
||||
: backgroundOption.value === 'green' ? 'rgba(34,197,94,0.3)'
|
||||
: backgroundOption.value === 'yellow' ? 'rgba(245,158,11,0.34)'
|
||||
: backgroundOption.value === 'red' ? 'rgba(239,68,68,0.3)'
|
||||
: backgroundOption.value === 'purple' ? 'rgba(168,85,247,0.3)'
|
||||
: 'rgba(236,72,153,0.3)'
|
||||
}"
|
||||
:style="{ background: getCalloutBackgroundSwatch(backgroundOption.value) }"
|
||||
></span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
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>
|
||||
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>
|
||||
@@ -12,25 +12,40 @@ const props = defineProps({
|
||||
|
||||
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 saveMessage = ref('')
|
||||
const saveError = ref('')
|
||||
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 actionMessage = ref('')
|
||||
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: ''
|
||||
})
|
||||
@@ -53,6 +68,7 @@ const syncMemberForm = () => {
|
||||
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 || ''
|
||||
}
|
||||
@@ -77,11 +93,72 @@ const normalizedLabels = computed(() => [...new Set(
|
||||
.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())
|
||||
const serializeMemberPayload = () => JSON.stringify({
|
||||
...getMemberPayload(),
|
||||
roleCode: form.roleCode
|
||||
})
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
@@ -157,18 +234,32 @@ const getMemberPayload = () => ({
|
||||
})
|
||||
|
||||
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(hasUnsavedMemberChanges)
|
||||
} = useAdminUnsavedChangesGuard(shouldBlockUnsavedMemberChanges)
|
||||
|
||||
/**
|
||||
* 회원 상세를 수정 모드로 전환한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const enterEditMode = () => {
|
||||
isEditingMember.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openAvatarFilePicker = () => {
|
||||
if (!isFormEditable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
avatarInputRef.value?.click()
|
||||
}
|
||||
|
||||
@@ -185,20 +276,33 @@ const uploadAvatar = async (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFormEditable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isUploadingAvatar.value = true
|
||||
saveError.value = ''
|
||||
saveMessage.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('files', file)
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.avatarUrl = result.files?.[0]?.url || ''
|
||||
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) {
|
||||
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.')
|
||||
} finally {
|
||||
isUploadingAvatar.value = false
|
||||
if (target) {
|
||||
@@ -212,6 +316,10 @@ const uploadAvatar = async (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeAvatar = () => {
|
||||
if (!isFormEditable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
form.avatarUrl = ''
|
||||
}
|
||||
|
||||
@@ -238,7 +346,6 @@ const closeActionMenu = () => {
|
||||
const openPasswordModal = () => {
|
||||
passwordForm.password = ''
|
||||
passwordForm.passwordConfirm = ''
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
passwordModalOpen.value = true
|
||||
closeActionMenu()
|
||||
@@ -250,7 +357,6 @@ const openPasswordModal = () => {
|
||||
*/
|
||||
const openDeleteModal = () => {
|
||||
deleteForm.confirmText = ''
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
deleteModalOpen.value = true
|
||||
closeActionMenu()
|
||||
@@ -285,7 +391,6 @@ const closeDeleteModal = () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const updateMemberPassword = async () => {
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
|
||||
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
|
||||
@@ -309,9 +414,11 @@ const updateMemberPassword = async () => {
|
||||
passwordModalOpen.value = false
|
||||
passwordForm.password = ''
|
||||
passwordForm.passwordConfirm = ''
|
||||
actionMessage.value = '비밀번호가 변경되었습니다.'
|
||||
showToast('success', '비밀번호가 변경되었습니다.')
|
||||
} catch (error) {
|
||||
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||
const message = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||
actionError.value = message
|
||||
showToast('error', message)
|
||||
} finally {
|
||||
isUpdatingPassword.value = false
|
||||
}
|
||||
@@ -322,7 +429,6 @@ const updateMemberPassword = async () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteMember = async () => {
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
|
||||
if (deleteForm.confirmText !== form.email) {
|
||||
@@ -337,7 +443,9 @@ const deleteMember = async () => {
|
||||
})
|
||||
emit('deleted')
|
||||
} catch (error) {
|
||||
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
|
||||
const message = error?.data?.message || '회원 삭제에 실패했습니다.'
|
||||
actionError.value = message
|
||||
showToast('error', message)
|
||||
} finally {
|
||||
isDeletingMember.value = false
|
||||
}
|
||||
@@ -352,12 +460,28 @@ const saveMember = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
saveMessage.value = ''
|
||||
saveError.value = ''
|
||||
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',
|
||||
@@ -370,9 +494,10 @@ const saveMember = async () => {
|
||||
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
emit('saved', saved)
|
||||
saveMessage.value = '저장되었습니다.'
|
||||
isEditingMember.value = isNewMember.value
|
||||
showToast('success', '저장되었습니다.')
|
||||
} catch (error) {
|
||||
saveError.value = error?.data?.message || '저장에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -424,8 +549,13 @@ watch(() => props.member, () => {
|
||||
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:opacity-50" type="button" :disabled="isSaving" @click="saveMember">
|
||||
{{ isSaving ? '저장 중' : '저장' }}
|
||||
<button
|
||||
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>
|
||||
@@ -439,6 +569,8 @@ watch(() => props.member, () => {
|
||||
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
|
||||
@@ -451,11 +583,11 @@ watch(() => props.member, () => {
|
||||
{{ memberInitial }}
|
||||
</span>
|
||||
<span class="admin-member-form__avatar-caption absolute inset-x-0 bottom-0 flex min-h-8 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{{ isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||
{{ !isFormEditable ? '현재 썸네일' : isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="form.avatarUrl"
|
||||
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="썸네일 제거"
|
||||
@@ -513,33 +645,68 @@ watch(() => props.member, () => {
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<label class="admin-member-form__field block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
|
||||
<input v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
|
||||
<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>
|
||||
<input v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
|
||||
<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>
|
||||
<input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
|
||||
<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>
|
||||
<textarea v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-transparent bg-[#eef1f4] px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
|
||||
<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>
|
||||
|
||||
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
|
||||
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
|
||||
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
|
||||
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||
</form>
|
||||
|
||||
<section v-if="!isNewMember" class="admin-member-form__activity">
|
||||
@@ -576,6 +743,21 @@ watch(() => props.member, () => {
|
||||
@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)]">
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
<script setup>
|
||||
import AdminNavPrimaryBranch from './AdminNavPrimaryBranch.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** buildNavigationEditorTree 결과 */
|
||||
wraps: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
/** 들여쓰기 단계 */
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/** 루트면 `'root'`, 아니면 부모 항목 id */
|
||||
parentKey: {
|
||||
type: String,
|
||||
default: 'root'
|
||||
},
|
||||
/** 드래그 중인 항목 id */
|
||||
draggingId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 드롭 대상 위에 올린 항목 id */
|
||||
dragOverId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'drag-start',
|
||||
'drag-over',
|
||||
'drag-end',
|
||||
'drop',
|
||||
'add-child',
|
||||
'remove'
|
||||
])
|
||||
|
||||
/**
|
||||
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @returns {boolean} true면 드래그를 막는다
|
||||
*/
|
||||
const shouldBlockRowDrag = (event) => {
|
||||
const el = event.target
|
||||
if (!el || typeof el.closest !== 'function') {
|
||||
return false
|
||||
}
|
||||
return Boolean(el.closest('input, button, textarea, select, a'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 시작
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @param {string} itemId - 항목 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDragStart = (event, itemId) => {
|
||||
if (shouldBlockRowDrag(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
emit('drag-start', { parentKey: props.parentKey, itemId })
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 오버
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @param {string} itemId - 항목 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDragOver = (event, itemId) => {
|
||||
event.preventDefault()
|
||||
emit('drag-over', itemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @param {string} itemId - 대상 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDrop = (event, itemId) => {
|
||||
event.preventDefault()
|
||||
emit('drop', { parentKey: props.parentKey, targetId: itemId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 종료
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDragEnd = () => {
|
||||
emit('drag-end')
|
||||
}
|
||||
|
||||
/**
|
||||
* 행 하이라이트 클래스(태그 관리 메인 태그 테이블과 동일 톤)
|
||||
* @param {string} itemId - 항목 id
|
||||
* @returns {string}
|
||||
*/
|
||||
const rowStateClass = (itemId) => {
|
||||
const id = String(itemId)
|
||||
if (props.dragOverId === id) {
|
||||
return 'bg-[#f9f9f7]'
|
||||
}
|
||||
if (props.draggingId === id) {
|
||||
return 'opacity-50'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-nav-primary-branch" :class="depth ? 'mt-2 border-l border-line pl-3' : ''">
|
||||
<div class="admin-nav-primary-branch__shell overflow-hidden rounded border border-line">
|
||||
<table class="admin-nav-primary-branch__table w-full border-collapse text-left text-sm">
|
||||
<thead v-if="depth === 0" class="admin-nav-primary-branch__head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||
#
|
||||
</th>
|
||||
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||
라벨
|
||||
</th>
|
||||
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||
URL
|
||||
</th>
|
||||
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-nav-primary-branch__body divide-y divide-line bg-white">
|
||||
<template v-for="(wrap, index) in wraps" :key="wrap.item.id">
|
||||
<tr
|
||||
class="admin-nav-primary-branch__row cursor-move"
|
||||
:class="rowStateClass(wrap.item.id)"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, wrap.item.id)"
|
||||
@dragover="onDragOver($event, wrap.item.id)"
|
||||
@drop="onDrop($event, wrap.item.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle text-muted">
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
|
||||
<input
|
||||
v-model="wrap.item.label"
|
||||
class="admin-nav-primary-branch__label w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||
type="text"
|
||||
placeholder="라벨"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
|
||||
<input
|
||||
v-model="wrap.item.url"
|
||||
class="admin-nav-primary-branch__url w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
|
||||
type="text"
|
||||
placeholder="URL (# 또는 /경로)"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
|
||||
<div class="admin-nav-primary-branch__actions flex flex-wrap gap-2">
|
||||
<button
|
||||
class="admin-nav-primary-branch__add-child rounded border border-line px-3 py-1.5 text-xs font-semibold"
|
||||
type="button"
|
||||
@click="emit('add-child', wrap.item.id)"
|
||||
>
|
||||
하위
|
||||
</button>
|
||||
<button
|
||||
class="admin-nav-primary-branch__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
|
||||
type="button"
|
||||
@click="emit('remove', wrap.item.id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="wrap.children.length" class="admin-nav-primary-branch__nest bg-white">
|
||||
<td class="p-0" colspan="4">
|
||||
<div class="admin-nav-primary-branch__nest-inner border-t border-line bg-[#fafaf8] px-2 py-3">
|
||||
<AdminNavPrimaryBranch
|
||||
:wraps="wrap.children"
|
||||
:depth="depth + 1"
|
||||
:parent-key="String(wrap.item.id)"
|
||||
:dragging-id="draggingId"
|
||||
:drag-over-id="dragOverId"
|
||||
@drag-start="emit('drag-start', $event)"
|
||||
@drag-over="emit('drag-over', $event)"
|
||||
@drag-end="emit('drag-end')"
|
||||
@drop="emit('drop', $event)"
|
||||
@add-child="emit('add-child', $event)"
|
||||
@remove="emit('remove', $event)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
135
components/admin/AdminSlashCommandIcon.vue
Normal file
135
components/admin/AdminSlashCommandIcon.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 슬래시 명령 메뉴 아이콘 (Ghost 스타일 라인 아이콘)
|
||||
*/
|
||||
const props = defineProps({
|
||||
/** @type {import('vue').PropType<string>} */
|
||||
commandId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="admin-slash-command-icon"
|
||||
:class="`admin-slash-command-icon--${commandId}`"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- image -->
|
||||
<template v-if="commandId === 'image'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="m19.642 16.276-3.85-7a.517.517 0 0 0-.181-.189.585.585 0 0 0-.749.115l-4.533 5.494-2.307-2.516a.548.548 0 0 0-.206-.14.598.598 0 0 0-.499.031.529.529 0 0 0-.183.164l-2.75 4a.468.468 0 0 0-.015.507.526.526 0 0 0 .202.189c.084.045.18.069.28.069H19.15a.594.594 0 0 0 .268-.063.532.532 0 0 0 .2-.174.462.462 0 0 0 .024-.487ZM9.25 9c.911 0 1.65-.672 1.65-1.5S10.161 6 9.25 6c-.91 0-1.65.672-1.65 1.5S8.34 9 9.25 9Z"></path>
|
||||
</template>
|
||||
|
||||
<!-- gallery -->
|
||||
<template v-else-if="commandId === 'gallery'">
|
||||
<g clip-path="url(#a)"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 5H3.2C1.985 5 1 5.806 1 6.8v14.4c0 .994.985 1.8 2.2 1.8h17.6c1.215 0 2.2-.806 2.2-1.8V6.8c0-.994-.985-1.8-2.2-1.8ZM6 1h12"></path><path fill="currentColor" d="M15.142 10.264a.75.75 0 0 1 .529.4l4 8A.75.75 0 0 1 19 19.75H6a.75.75 0 0 1-.498-1.31l9-8a.75.75 0 0 1 .64-.176ZM7 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"></path></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h24v24H0z"></path></clipPath></defs>
|
||||
</template>
|
||||
|
||||
<!-- h1 -->
|
||||
<template v-else-if="commandId === 'h1'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M17.926 6.578v10.898c0 .602.33.963.862.963.541 0 .862-.351.862-.963V5.726c0-.682-.451-1.153-1.093-1.153-.39 0-.742.15-1.373.622l-2.026 1.504c-.4.29-.591.561-.591.852 0 .38.3.692.672.692.22 0 .43-.08.721-.291l1.885-1.374zM4.42 4.903a.77.77 0 0 1 .77.77v5.35h6.168v-5.35a.77.77 0 1 1 1.54 0v12.242a.77.77 0 0 1-1.54 0v-5.351H5.19v5.351a.77.77 0 1 1-1.54 0V5.673a.77.77 0 0 1 .77-.77"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- h2 -->
|
||||
<template v-else-if="commandId === 'h2'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M14.159 16c-.562.638-.725.905-.725 1.248 0 .553.439.886 1.135.886h6.289c.524 0 .829-.276.829-.724 0-.457-.324-.734-.83-.734H15.57v-.114l3.526-4.031c1.715-1.953 2.201-2.859 2.201-4.098 0-2.096-1.648-3.583-3.993-3.583-2.515 0-4.088 1.697-4.088 3.317 0 .514.305.867.772.867.39 0 .658-.258.791-.763.286-1.238 1.191-1.972 2.42-1.972 1.449 0 2.411.896 2.411 2.24 0 .895-.41 1.695-1.486 2.925zM3.419 5.364c.404 0 .731.327.731.731v5.087h5.863V6.095a.732.732 0 1 1 1.464 0v11.637a.732.732 0 0 1-1.464 0v-5.087H4.15v5.087a.732.732 0 1 1-1.463 0V6.095c0-.404.327-.731.732-.731"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- h3 -->
|
||||
<template v-else-if="commandId === 'h3'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4.05 5.856a.75.75 0 0 0-1.5 0v11.921a.75.75 0 1 0 1.5 0v-5.21h6.006v5.21a.75.75 0 0 0 1.5 0V5.856a.75.75 0 0 0-1.5 0v5.21H4.05zm9.479 9.234c-.418 0-.713.304-.713.732 0 1.454 1.796 2.936 4.248 2.936 2.642 0 4.486-1.558 4.486-3.782 0-1.635-1.226-3.041-2.832-3.222v-.095c1.321-.228 2.395-1.596 2.395-3.031 0-1.977-1.692-3.393-4.068-3.393-2.338 0-3.925 1.425-3.925 2.898 0 .476.285.79.723.79.37 0 .608-.2.798-.723.38-.979 1.235-1.54 2.366-1.54 1.454 0 2.433.875 2.433 2.177s-1.007 2.242-2.395 2.242h-1.121c-.456 0-.76.295-.76.713 0 .409.323.723.76.723h1.188c1.654 0 2.765.978 2.765 2.432s-1.083 2.386-2.784 2.386c-1.293 0-2.281-.57-2.775-1.587-.247-.485-.456-.656-.789-.656"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- h4 -->
|
||||
<template v-else-if="commandId === 'h4'">
|
||||
<path fill="currentColor" d="M140-290v-380h60v160h180v-160h60v380h-60v-160H200v160h-60Zm580 0v-120H520v-260h60v200h140v-200h60v200h80v60h-80v120h-60Z"/>
|
||||
</template>
|
||||
|
||||
<!-- quote -->
|
||||
<template v-else-if="commandId === 'quote'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M5 10.966v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024H6.704A3.35 3.35 0 0 1 9.845 7.75v-1.5A4.845 4.845 0 0 0 5 10.966m8 0v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024h-3.415a3.35 3.35 0 0 1 3.141-2.192v-1.5A4.845 4.845 0 0 0 13 10.966"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- list -->
|
||||
<template v-else-if="commandId === 'list'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4.3 7.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2m4.033-1.75a.75.75 0 0 0 0 1.5l11.917.001a.75.75 0 0 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 1 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 0 0 0-1.5zM5.3 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1 6.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- code -->
|
||||
<template v-else-if="commandId === 'code'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 6A2.75 2.75 0 0 1 5 3.25h14A2.75 2.75 0 0 1 21.75 6v12A2.75 2.75 0 0 1 19 20.75H5A2.75 2.75 0 0 1 2.25 18zM5 4.75c-.69 0-1.25.56-1.25 1.25v12c0 .69.56 1.25 1.25 1.25h14c.69 0 1.25-.56 1.25-1.25V6c0-.69-.56-1.25-1.25-1.25zm5.53 4.62a.75.75 0 0 1 0 1.06l-1.59 1.591 1.59 1.591a.75.75 0 0 1-1.06 1.06l-2.122-2.12a.75.75 0 0 1 0-1.061L9.47 9.37a.75.75 0 0 1 1.06 0m2.94 1.06a.75.75 0 1 1 1.06-1.06l2.122 2.12a.75.75 0 0 1 0 1.062l-2.122 2.12a.75.75 0 1 1-1.06-1.06l1.59-1.59z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- divider -->
|
||||
<template v-else-if="commandId === 'divider'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4 4.25a.75.75 0 0 1 .75.75v1c0 .69.56 1.25 1.25 1.25h12c.69 0 1.25-.56 1.25-1.25V5a.75.75 0 0 1 1.5 0v1A2.75 2.75 0 0 1 18 8.75H6A2.75 2.75 0 0 1 3.25 6V5A.75.75 0 0 1 4 4.25m0 15.5a.75.75 0 0 0 .75-.75v-1c0-.69.56-1.25 1.25-1.25h12c.69 0 1.25.56 1.25 1.25v1a.75.75 0 0 0 1.5 0v-1A2.75 2.75 0 0 0 18 15.25H6A2.75 2.75 0 0 0 3.25 18v1c0 .414.336.75.75.75m-1-8.5a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5H3m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5H7.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5h-1.2m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5h-1.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5H21a.75.75 0 0 0 0-1.5h-1.2"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- callout -->
|
||||
<template v-else-if="commandId === 'callout'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="M12 18a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.5v6"></path>
|
||||
</template>
|
||||
|
||||
<!-- toggle -->
|
||||
<template v-else-if="commandId === 'toggle'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 11 12 15l-4.5-4"></path>
|
||||
</template>
|
||||
|
||||
<!-- embed -->
|
||||
<template v-else-if="commandId === 'embed'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm.5 16c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5V9.8l4.7-5.3H19c.3 0 .5.2.5.5v14zm-6-9.5L16 12l-2.5 2.8 1.1 1L18 12l-3.5-3.5-1 1zm-3 0l-1-1L6 12l3.5 3.8 1.1-1L8 12l2.5-2.5z"></path>
|
||||
</template>
|
||||
|
||||
<!-- fallback -->
|
||||
<template v-else>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M12 4.5c.46 0 .833.373.833.833v5.834h5.834a.833.833 0 0 1 0 1.666h-5.834v5.834a.833.833 0 0 1-1.666 0v-5.834H5.333a.833.833 0 0 1 0-1.666h5.834V5.333c0-.46.373-.833.833-.833"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
defaultTagType: {
|
||||
type: String,
|
||||
default: 'general'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,7 +68,7 @@ const submitTag = () => {
|
||||
description: form.description.trim(),
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: props.initialTag.tagType || 'general'
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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)
|
||||
|
||||
/**
|
||||
* 댓글 시간을 상대 시간 형식으로 변환한다.
|
||||
@@ -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>
|
||||
|
||||
|
||||
128
components/content/ContentMarkdownCalloutEditor.vue
Normal file
128
components/content/ContentMarkdownCalloutEditor.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { buildCalloutOpenerLine } from '../../lib/markdown-callout.js'
|
||||
import ProseCallout from './ProseCallout.vue'
|
||||
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 콜아웃 본문 */
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
calloutEmojiEnabled: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
calloutEmoji: {
|
||||
type: String,
|
||||
default: '💡'
|
||||
},
|
||||
calloutTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
calloutBackground: {
|
||||
type: String,
|
||||
default: 'blue'
|
||||
},
|
||||
/** 본문 첫 줄 source-line(0-based) */
|
||||
bodySourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
/** 콜아웃 선언 줄 source-line(0-based) */
|
||||
blockSourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['commit', 'delete-line', 'insert-above', 'insert-below', 'merge-with-previous', 'leave-block'])
|
||||
|
||||
const bodyLines = computed(() => {
|
||||
const lines = String(props.modelValue ?? '').replace(/\r/g, '').split('\n')
|
||||
return lines.length ? lines : ['']
|
||||
})
|
||||
|
||||
/**
|
||||
* 콜아웃 마크다운 줄을 반영한다.
|
||||
* @param {string[]} contentLines - 본문 줄
|
||||
* @returns {void}
|
||||
*/
|
||||
const commitCalloutLines = (contentLines) => {
|
||||
emit('commit', [
|
||||
buildCalloutOpenerLine({
|
||||
calloutEmojiEnabled: props.calloutEmojiEnabled,
|
||||
calloutEmoji: props.calloutEmoji,
|
||||
calloutBackground: props.calloutBackground,
|
||||
title: props.calloutTitle
|
||||
}),
|
||||
...contentLines,
|
||||
':::'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜아웃 본문 문자열을 줄 목록으로 정규화한다.
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {string[]} 본문 줄
|
||||
*/
|
||||
const normalizeBodyLines = (payload) => {
|
||||
const value = typeof payload === 'string'
|
||||
? payload
|
||||
: String(payload?.value ?? '')
|
||||
|
||||
const lines = String(value ?? '').replace(/\r/g, '').split('\n')
|
||||
return lines.length ? lines : ['']
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 편집 반영
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyCommit = (payload) => {
|
||||
commitCalloutLines(normalizeBodyLines(payload))
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 입력 중 마크다운을 동기화한다.
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyInput = (payload) => {
|
||||
commitCalloutLines(normalizeBodyLines(payload))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="content-markdown-callout-editor relative"
|
||||
:data-source-line="blockSourceLine"
|
||||
>
|
||||
<ProseCallout
|
||||
:emoji-enabled="calloutEmojiEnabled"
|
||||
:emoji="calloutEmoji"
|
||||
:background="calloutBackground"
|
||||
:title="calloutTitle"
|
||||
>
|
||||
<ContentMarkdownEditableInline
|
||||
block-class="content-markdown-callout-editor__body min-w-0 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
enter-mode="multiline"
|
||||
plain-text
|
||||
arrow-exit-creates-line
|
||||
preserve-empty-line-on-full-delete
|
||||
:source-line="bodySourceLine"
|
||||
:source-line-count="bodyLines.length"
|
||||
:model-value="modelValue"
|
||||
@input="onBodyInput"
|
||||
@commit="onBodyCommit"
|
||||
@delete-line="emit('delete-line', $event)"
|
||||
@insert-above="emit('insert-above', $event)"
|
||||
@insert-below="emit('insert-below', $event)"
|
||||
@merge-with-previous="emit('merge-with-previous', bodySourceLine, $event)"
|
||||
@leave-block="emit('leave-block', $event)"
|
||||
/>
|
||||
</ProseCallout>
|
||||
</div>
|
||||
</template>
|
||||
190
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
190
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import { buildCodeBlockLines } from '../../lib/markdown-code-block.js'
|
||||
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 코드 본문 */
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 언어(slug) */
|
||||
language: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 줄 번호 표시 */
|
||||
showLineNumbers: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/** 본문 첫 줄 source-line(0-based) */
|
||||
bodySourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['commit', 'insert-above', 'insert-below', 'delete-line'])
|
||||
|
||||
const languageDraft = ref(props.language)
|
||||
const lineNumbersEnabled = ref(props.showLineNumbers)
|
||||
const liveBody = ref(props.modelValue)
|
||||
|
||||
watch(() => props.language, (value) => {
|
||||
languageDraft.value = value
|
||||
})
|
||||
|
||||
watch(() => props.showLineNumbers, (value) => {
|
||||
lineNumbersEnabled.value = value
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
liveBody.value = value
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<string[]>} */
|
||||
const bodyLines = computed(() => {
|
||||
const text = String(liveBody.value ?? '')
|
||||
|
||||
if (!text.length) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
return text.split('\n')
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<number[]>} */
|
||||
const gutterLines = computed(() => bodyLines.value.map((_, index) => index + 1))
|
||||
|
||||
/**
|
||||
* 마크다운에 코드 블록을 반영한다.
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const commitCodeBlock = (body) => {
|
||||
emit('commit', buildCodeBlockLines({
|
||||
language: languageDraft.value,
|
||||
showLineNumbers: lineNumbersEnabled.value,
|
||||
body
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 편집 반영
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyCommit = (body) => {
|
||||
liveBody.value = body
|
||||
commitCodeBlock(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 중 줄 번호 갱신용 본문 동기화
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyInput = (body) => {
|
||||
liveBody.value = body
|
||||
commitCodeBlock(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 블록 아래로 이탈(다음 문단 생성)
|
||||
* @param {Object} payload - insert-below 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onExitBelow = (payload) => {
|
||||
emit('insert-below', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 언어 입력 반영
|
||||
* @returns {void}
|
||||
*/
|
||||
const onLanguageCommit = () => {
|
||||
commitCodeBlock(props.modelValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄 번호 표시를 토글한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleLineNumbers = () => {
|
||||
lineNumbersEnabled.value = !lineNumbersEnabled.value
|
||||
commitCodeBlock(props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProseCodeBlock
|
||||
class="content-markdown-code-block-editor"
|
||||
:show-line-numbers="lineNumbersEnabled"
|
||||
:line-numbers="gutterLines"
|
||||
>
|
||||
<template #header-tools>
|
||||
<div
|
||||
class="content-markdown-code-block-editor__toolbar pointer-events-none flex items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<button
|
||||
class="content-markdown-code-block-editor__line-numbers pointer-events-auto rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/15 hover:text-white"
|
||||
type="button"
|
||||
:aria-pressed="lineNumbersEnabled"
|
||||
:title="lineNumbersEnabled ? '줄 번호 숨기기' : '줄 번호 표시'"
|
||||
@mousedown.prevent
|
||||
@click="toggleLineNumbers"
|
||||
>
|
||||
{{ lineNumbersEnabled ? '줄번호' : '줄번호 끔' }}
|
||||
</button>
|
||||
<input
|
||||
v-model="languageDraft"
|
||||
class="content-markdown-code-block-editor__language pointer-events-auto w-[7.5rem] rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs text-white outline-none transition-colors placeholder:text-white/35 focus:border-white/30 focus:bg-white/15"
|
||||
type="text"
|
||||
placeholder="Language..."
|
||||
spellcheck="false"
|
||||
@mousedown.stop
|
||||
@keydown.stop
|
||||
@blur="onLanguageCommit"
|
||||
@keydown.enter.prevent="onLanguageCommit"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ContentMarkdownEditableInline
|
||||
tag="pre"
|
||||
block-class="content-markdown-code-block-editor__editor m-0 min-w-0 border-0 bg-transparent p-0 font-mono text-sm leading-6 text-white outline-none"
|
||||
enter-mode="multiline"
|
||||
plain-text
|
||||
arrow-exit-creates-line
|
||||
:source-line="bodySourceLine"
|
||||
:source-line-count="bodyLines.length"
|
||||
:model-value="modelValue"
|
||||
@input="onBodyInput"
|
||||
@commit="onBodyCommit"
|
||||
@insert-above="emit('insert-above', $event)"
|
||||
@insert-below="onExitBelow"
|
||||
@delete-line="emit('delete-line', $event)"
|
||||
/>
|
||||
</ProseCodeBlock>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-markdown-code-block-editor :deep(.prose-code-block__content) {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.content-markdown-code-block-editor :deep(.prose-code-block__header) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-markdown-code-block-editor__toolbar {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-markdown-code-block-editor__toolbar > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
1521
components/content/ContentMarkdownEditableInline.vue
Normal file
1521
components/content/ContentMarkdownEditableInline.vue
Normal file
File diff suppressed because it is too large
Load Diff
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-[#15171a]', 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>
|
||||
|
||||
@@ -8,6 +8,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '💡'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
default: 'blue'
|
||||
@@ -27,62 +31,63 @@ const backgroundClass = computed(() => {
|
||||
return 'prose-callout--yellow'
|
||||
}
|
||||
|
||||
if (props.background === 'red') {
|
||||
return 'prose-callout--red'
|
||||
if (props.background === 'blue') {
|
||||
return 'prose-callout--blue'
|
||||
}
|
||||
|
||||
if (props.background === 'purple') {
|
||||
return 'prose-callout--purple'
|
||||
}
|
||||
|
||||
if (props.background === 'pink') {
|
||||
return 'prose-callout--pink'
|
||||
}
|
||||
|
||||
return 'prose-callout--blue'
|
||||
return 'prose-callout--red'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="prose-callout prose-callout-card mt-8 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
class="prose-callout prose-callout-card mb-2.5 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
:class="backgroundClass"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="emojiEnabled" class="inline-flex shrink-0 text-[20px] leading-none">{{ emoji || '💡' }}</span>
|
||||
<div class="min-w-0 flex-1 whitespace-pre-line">
|
||||
<slot />
|
||||
</div>
|
||||
<div 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: rgba(100, 116, 139, 0.12);
|
||||
background: color-mix(in srgb, #050505 10%, #ffffff);
|
||||
border: 1px solid #050505;
|
||||
}
|
||||
|
||||
.prose-callout--blue {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
background: color-mix(in srgb, #0055ff 10%, #ffffff);
|
||||
border: 1px solid #0055ff;
|
||||
}
|
||||
|
||||
.prose-callout--green {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
background: color-mix(in srgb, #16ae68 10%, #ffffff);
|
||||
border: 1px solid #16ae68;
|
||||
}
|
||||
|
||||
.prose-callout--yellow {
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
background: color-mix(in srgb, #ffff00 10%, #ffffff);
|
||||
border: 1px solid #ffff00;
|
||||
}
|
||||
|
||||
.prose-callout--red {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
background: color-mix(in srgb, #ff0000 10%, #ffffff);
|
||||
border: 1px solid #ff0000;
|
||||
}
|
||||
|
||||
.prose-callout--purple {
|
||||
background: rgba(168, 85, 247, 0.14);
|
||||
}
|
||||
|
||||
.prose-callout--pink {
|
||||
background: rgba(236, 72, 153, 0.14);
|
||||
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,48 @@ 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인지 확인한다.
|
||||
@@ -92,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"
|
||||
@@ -108,11 +211,25 @@ 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-if="safeExternalUrl"
|
||||
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"
|
||||
|
||||
@@ -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"
|
||||
: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>
|
||||
@@ -7,6 +7,13 @@ defineProps({
|
||||
})
|
||||
|
||||
const { isDarkMode, toggleTheme } = useThemeMode()
|
||||
const route = useRoute()
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
adPostSidebarCode: ''
|
||||
})
|
||||
})
|
||||
|
||||
const { data: tags } = await useFetch('/api/tags', {
|
||||
default: () => []
|
||||
@@ -15,10 +22,15 @@ const { data: tags } = await useFetch('/api/tags', {
|
||||
const { data: navigation } = await useFetch('/api/navigation', {
|
||||
default: () => ({
|
||||
primary: [],
|
||||
footer: []
|
||||
footer: [],
|
||||
recommended: []
|
||||
})
|
||||
})
|
||||
|
||||
/** 저자 영역 공개 여부 */
|
||||
const showAuthorSection = false
|
||||
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||
|
||||
const STORAGE_KEY = 'sori-primary-nav-expanded'
|
||||
|
||||
/**
|
||||
@@ -158,7 +170,7 @@ onMounted(() => {
|
||||
<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 }" />
|
||||
@@ -173,7 +185,7 @@ onMounted(() => {
|
||||
</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>
|
||||
@@ -189,6 +201,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteAdSlot
|
||||
v-if="isPostDetailRoute"
|
||||
class="left-sidebar__post-ad-slot site-sidebar-section px-5 py-5 pr-3 max-lg:hidden xl:pl-0"
|
||||
:code="siteSettings?.adPostSidebarCode"
|
||||
location="post-sidebar-left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">
|
||||
@@ -206,7 +225,7 @@ onMounted(() => {
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<button
|
||||
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
|
||||
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,29 @@ 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"
|
||||
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>
|
||||
|
||||
62
components/site/PostCardMedia.vue
Normal file
62
components/site/PostCardMedia.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 게시물 링크 */
|
||||
to: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 게시물 제목 */
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 대표 이미지 URL */
|
||||
featuredImage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 썸네일 비율·크기 Tailwind 클래스 */
|
||||
aspectClass: {
|
||||
type: String,
|
||||
default: 'aspect-square sm:aspect-video'
|
||||
},
|
||||
/** 링크 래퍼 추가 클래스 */
|
||||
linkClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 이미지 추가 클래스 */
|
||||
imageClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
class="post-card-media relative block"
|
||||
:class="linkClass"
|
||||
data-post-card-media
|
||||
>
|
||||
<figure class="post-card-media__figure overflow-hidden rounded-[10px]">
|
||||
<img
|
||||
v-if="featuredImage"
|
||||
class="post-card-media__image w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
|
||||
:class="[aspectClass, imageClass]"
|
||||
:src="featuredImage"
|
||||
:alt="title"
|
||||
loading="lazy"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="post-card-media__placeholder flex w-full items-center justify-center rounded-[inherit] bg-[#F7F4EF] p-4 text-center text-xs leading-snug text-[var(--site-muted)] transition-opacity duration-200 group-hover:opacity-90"
|
||||
:class="aspectClass"
|
||||
:aria-label="title"
|
||||
>
|
||||
<span class="post-card-media__placeholder-text line-clamp-4">{{ title }}</span>
|
||||
</span>
|
||||
</figure>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -1,12 +1,12 @@
|
||||
<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: () => ({
|
||||
@@ -14,17 +14,235 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
description: 'sori.studio 개인 블로그',
|
||||
logoText: '井',
|
||||
logoUrl: '',
|
||||
socialLinks: [],
|
||||
copyrightText: '©2026 sori.studio'
|
||||
})
|
||||
})
|
||||
|
||||
const { data: navigation } = await useFetch('/api/navigation', {
|
||||
default: () => ({
|
||||
primary: [],
|
||||
footer: [],
|
||||
recommended: []
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 공개 추천 사이트 목록(비가시 제외)
|
||||
* @returns {Array<{ id: string, label: string, url: string, descriptionText?: string, thumbnailUrl?: string }>}
|
||||
*/
|
||||
const recommendedSites = computed(() => {
|
||||
const list = navigation.value?.recommended
|
||||
if (!Array.isArray(list)) {
|
||||
return []
|
||||
}
|
||||
return list.filter((x) => x?.isVisible !== false)
|
||||
})
|
||||
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||
const sidebarAdCode = computed(() => isPostDetailRoute.value ? '' : siteSettings.value?.adSidebarCode)
|
||||
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
|
||||
const followLinks = computed(() => getVisibleSocialLinks(siteSettings.value?.socialLinks || []))
|
||||
|
||||
/**
|
||||
* 고정 상단 영역을 고려한 TOC 판정 기준선을 반환한다.
|
||||
* @returns {number} 뷰포트 상단 기준 오프셋
|
||||
*/
|
||||
const getTocActivationOffset = () => {
|
||||
if (!import.meta.client) {
|
||||
return 96
|
||||
}
|
||||
|
||||
const topChromeHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--site-top-chrome-height'))
|
||||
|
||||
return (Number.isFinite(topChromeHeight) ? topChromeHeight : 57) + 28
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 본문 스크롤 위치에 해당하는 TOC 항목 ID를 계산한다.
|
||||
* @returns {string} 활성 제목 ID
|
||||
*/
|
||||
const findActiveTocId = () => {
|
||||
if (!import.meta.client || !postTocItems.value.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const offset = getTocActivationOffset()
|
||||
const currentY = window.scrollY + offset
|
||||
let activeId = postTocItems.value[0].id
|
||||
|
||||
for (const item of postTocItems.value) {
|
||||
const target = document.getElementById(item.id)
|
||||
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
|
||||
const targetY = target.getBoundingClientRect().top + window.scrollY
|
||||
|
||||
if (targetY <= currentY) {
|
||||
activeId = item.id
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return activeId
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 TOC 링크가 내부 스크롤 영역 안에 보이도록 보정한다.
|
||||
* @param {string} id - 활성 제목 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const scrollActiveTocIntoView = (id) => {
|
||||
if (!import.meta.client || !id || !(tocNavRef.value instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const nav = tocNavRef.value
|
||||
const link = nav.querySelector(`[data-toc-id="${id}"]`)
|
||||
|
||||
if (!(link instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const navTop = nav.scrollTop
|
||||
const navBottom = navTop + nav.clientHeight
|
||||
const linkTop = link.offsetTop
|
||||
const linkBottom = linkTop + link.offsetHeight
|
||||
const buffer = 24
|
||||
|
||||
if (linkTop < navTop + buffer) {
|
||||
nav.scrollTo({
|
||||
top: Math.max(0, linkTop - buffer),
|
||||
behavior: 'smooth'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (linkBottom > navBottom - buffer) {
|
||||
nav.scrollTo({
|
||||
top: linkBottom - nav.clientHeight + buffer,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크롤 이벤트에서 TOC 활성 항목을 갱신한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveToc = () => {
|
||||
if (!import.meta.client || tocScrollFrame) {
|
||||
return
|
||||
}
|
||||
|
||||
tocScrollFrame = window.requestAnimationFrame(() => {
|
||||
tocScrollFrame = 0
|
||||
const nextActiveId = findActiveTocId()
|
||||
|
||||
if (!nextActiveId || nextActiveId === activeTocId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
activeTocId.value = nextActiveId
|
||||
scrollActiveTocIntoView(nextActiveId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 탭으로 열 외부 URL인지
|
||||
* @param {string} url - 링크
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isExternalNavUrl = (url) => /^https?:\/\//i.test(String(url || '').trim())
|
||||
|
||||
/**
|
||||
* 추천 사이트 보조 문구를 반환한다.
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @returns {string} 표시 문구
|
||||
*/
|
||||
const getRecommendedDisplayText = (item) => {
|
||||
return String(item?.descriptionText || '').trim() || String(item?.url || '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 사이트 이미지 URL을 반환한다.
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @returns {string} 이미지 URL
|
||||
*/
|
||||
const getRecommendedImageUrl = (item) => {
|
||||
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
||||
return thumbnailUrl || getExternalFaviconUrl(item?.url, 64)
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 목차 링크를 부드럽게 이동한다.
|
||||
* @param {MouseEvent} event - 클릭 이벤트
|
||||
* @param {string} id - 이동할 제목 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const scrollToTocItem = (event, id) => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = document.getElementById(id)
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
activeTocId.value = id
|
||||
scrollActiveTocIntoView(id)
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
})
|
||||
window.history.replaceState(null, '', `${route.path}#${id}`)
|
||||
}
|
||||
|
||||
/** 소개 영역 공개 여부 */
|
||||
const showAboutSection = false
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', updateActiveToc, { passive: true })
|
||||
window.addEventListener('resize', updateActiveToc)
|
||||
nextTick(updateActiveToc)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
window.removeEventListener('scroll', updateActiveToc)
|
||||
window.removeEventListener('resize', updateActiveToc)
|
||||
|
||||
if (tocScrollFrame) {
|
||||
window.cancelAnimationFrame(tocScrollFrame)
|
||||
tocScrollFrame = 0
|
||||
}
|
||||
})
|
||||
|
||||
watch([postTocItems, () => route.fullPath], async () => {
|
||||
activeTocId.value = ''
|
||||
await nextTick()
|
||||
updateActiveToc()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="right-sidebar site-sidebar flex w-full flex-col overflow-hidden border-[var(--site-line)] max-lg:border-l-0 max-lg:border-t max-lg:px-4 max-lg:pb-10 lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:w-[287px] lg:self-start lg:border-l lg:border-t-0 lg:px-0">
|
||||
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0">
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-5 sm:pr-0 max-lg:px-0">
|
||||
<div class="right-sidebar__profile flex items-center gap-3">
|
||||
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
<img
|
||||
v-if="siteSettings.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
@@ -42,15 +260,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="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
|
||||
@@ -59,11 +271,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'"
|
||||
@@ -74,7 +286,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>
|
||||
@@ -87,7 +299,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" />
|
||||
@@ -101,7 +313,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>
|
||||
@@ -114,7 +326,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" />
|
||||
@@ -129,12 +341,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"
|
||||
@@ -144,11 +391,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>
|
||||
@@ -156,27 +402,84 @@ 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 site-sidebar-section py-5 pl-5 pr-0 max-lg:hidden"
|
||||
>
|
||||
<div class="right-sidebar__row flex items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
TOC
|
||||
</p>
|
||||
</div>
|
||||
<nav ref="tocNavRef" class="right-sidebar__toc-nav mt-4 max-h-[min(28rem,calc(100vh-18rem))] overflow-y-auto pr-2" aria-label="게시글 목차">
|
||||
<ul v-if="postTocItems.length" class="right-sidebar__toc-list list-none space-y-2 p-0">
|
||||
<li v-for="item in postTocItems" :key="item.id">
|
||||
<a
|
||||
class="right-sidebar__toc-link site-interactive block rounded-md border-l-2 py-1.5 pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
|
||||
:class="{
|
||||
'border-[var(--site-accent)] bg-[var(--site-panel)] text-[var(--site-accent)] font-semibold': activeTocId === item.id,
|
||||
'border-transparent text-[var(--site-text)]': activeTocId !== item.id,
|
||||
'pl-2 font-semibold': item.level === 1,
|
||||
'pl-5': item.level === 2,
|
||||
'pl-8 text-xs': item.level === 3,
|
||||
'site-muted': item.level === 3 && activeTocId !== item.id
|
||||
}"
|
||||
:href="`#${item.id}`"
|
||||
:aria-current="activeTocId === item.id ? 'location' : undefined"
|
||||
:data-toc-id="item.id"
|
||||
@click="scrollToTocItem($event, item.id)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="right-sidebar__toc-empty text-sm site-muted">
|
||||
목차로 표시할 제목이 없습니다.
|
||||
</p>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="recommendedSites.length"
|
||||
class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0"
|
||||
>
|
||||
<div class="right-sidebar__row flex items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
Recommended
|
||||
</p>
|
||||
<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>
|
||||
@@ -184,6 +487,12 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
About {{ siteSettings.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<SiteAdSlot
|
||||
class="right-sidebar__ad-slot site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0"
|
||||
:code="sidebarAdCode"
|
||||
location="sidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-3 text-xs site-muted max-lg:px-0">
|
||||
@@ -191,3 +500,15 @@ 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,17 +15,17 @@ const toggleBranch = inject('sidebarPrimaryNavToggle')
|
||||
const navBarBeforeBase =
|
||||
"before:pointer-events-none before:content-[''] before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full"
|
||||
|
||||
/** 비활성 경로: 기본 회색 막대, 호버 시 원형·믹스 색 */
|
||||
/** 비활성 경로: 테두리 톤에 가깝게 밝게 섞인 막대, 호버 시 원형·믹스 색 */
|
||||
const navBarBeforeInactive =
|
||||
`${navBarBeforeBase} before:bg-[var(--site-line)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
|
||||
`${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-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
|
||||
/** 행 공통: site-sidebar-nav-row, flex, 패딩 전환(가로 전체 호버 배경) */
|
||||
const navRowShell =
|
||||
'site-panel-hover flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
|
||||
'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'
|
||||
|
||||
/**
|
||||
* 노드가 펼쳐져 있는지
|
||||
@@ -122,7 +122,7 @@ const navLinkClass = (url) => {
|
||||
:class="isExpanded(node.id) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
||||
>
|
||||
<div class="sidebar-primary-nav-list__sub-clip min-h-0 overflow-hidden">
|
||||
<ul class="sidebar-primary-nav-list__sub ml-0 border-l border-[var(--site-line)] pl-2 pt-0">
|
||||
<ul class="sidebar-primary-nav-list__sub ml-0 mt-1 pl-3 pt-0">
|
||||
<SidebarPrimaryNavList :nodes="node.children" />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
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>
|
||||
@@ -8,8 +8,7 @@ const member = ref(null)
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'sori.studio',
|
||||
logoUrl: ''
|
||||
title: 'sori.studio'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,7 +151,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="site-header sticky top-0 z-20 backdrop-blur">
|
||||
<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="/">
|
||||
@@ -167,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" />
|
||||
@@ -185,13 +176,15 @@ 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>
|
||||
<img
|
||||
v-if="siteSettings.logoUrl"
|
||||
class="site-header__brand-logo h-7 w-7 shrink-0 rounded-md object-cover"
|
||||
:src="siteSettings.logoUrl"
|
||||
:alt="siteSettings.title"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
@@ -202,7 +195,13 @@ onBeforeUnmount(() => {
|
||||
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>
|
||||
@@ -224,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>
|
||||
|
||||
@@ -255,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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
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 '';
|
||||
@@ -1,5 +1,482 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## 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 방식으로 변경.
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
## 스타일
|
||||
|
||||
- TailwindCSS 기본 사용
|
||||
- 다크 인증(`signin`/`signup`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
|
||||
- 다크 인증(`signin`/`signup`/`admin/login`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
|
||||
- 관리자 레이아웃(`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`가 중복 주입될 수 있음).
|
||||
- 관리자 글 에디터는 Markdown-first textarea 편집을 기준으로 하며 저장 값은 기존 마크다운 문자열을 유지
|
||||
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast`의 `showToast`로 우측 상단(`z-[100]`)에 표시한다.
|
||||
|
||||
217
docs/deploy.md
217
docs/deploy.md
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 `npm run build`, `docker compose --env-file .env.production config --quiet`, `docker compose --env-file .env.production build sori-studio` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.5.75에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### v1.5.75 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성·수정 화면 오른쪽 설정 패널 하단에 본문 통계가 표시되는지 확인한다.
|
||||
- 본문 입력 시 단어 수, 공백 제외 문자 수, 공백 수, 읽기 시간, 블록 수, 이미지 수가 갱신되는지 확인한다.
|
||||
|
||||
### v1.5.74 참고
|
||||
|
||||
- DB 마이그레이션 `053_site_settings_post_sidebar_ad.sql` 적용 필요.
|
||||
- 사이트 설정 Ads에서 게시물 왼쪽 사이드 광고 코드를 저장한 뒤 게시물 상세 데스크톱 왼쪽 사이드바 하단에만 표시되는지 확인한다.
|
||||
- 게시물 상세 오른쪽 사이드바에서는 공통 오른쪽 사이드 광고가 표시되지 않는지 확인한다.
|
||||
- 긴 게시물에서 인아티클 광고가 본문 길이에 따라 0~2회로 제한되는지 확인한다.
|
||||
|
||||
### 필수 조건
|
||||
|
||||
- Node.js 22 LTS 권장
|
||||
@@ -45,6 +58,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` 서비스만 실행한다.
|
||||
@@ -68,6 +103,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
|
||||
@@ -136,8 +276,56 @@ cp .env.example .env.production
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
코드 변경 없이 `.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`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다.
|
||||
@@ -185,14 +373,34 @@ docker compose --env-file .env.production up -d --build
|
||||
| `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 분리 검증 절차
|
||||
|
||||
검증 전제는 실제 비밀번호나 전체 `DATABASE_URL`을 화면 공유, 문서, 커밋 메시지에 노출하지 않는 것이다. 확인할 때는 호스트, 포트, DB 이름, 파일명만 대조한다.
|
||||
@@ -322,9 +530,12 @@ docker compose --env-file .env.production restart sori-studio-db
|
||||
|
||||
- 관리자 글쓰기에서 업로드한 이미지는 `/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`가 앱 한도보다 작지 않은지 함께 확인한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
## 사용자 액션 필요 항목
|
||||
|
||||
426
docs/history.md
426
docs/history.md
@@ -1,5 +1,431 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-05 v1.5.75 — 작성 통계는 광고 기준과 같은 문자 기준을 보여준다
|
||||
|
||||
게시물 인아티클 광고가 본문 길이를 기준으로 노출되므로 작성 화면에서도 길이 감각을 바로 확인할 수 있어야 한다. 작성 흐름을 방해하지 않도록 본문 위에 떠 있는 표시 대신 오른쪽 설정 패널 하단에 작은 통계 영역을 두고, 문자 수는 광고 기준과 같은 공백 제외 값을 우선 표시한다. 단어 수와 읽기 시간, 블록·이미지 수는 글의 분량과 구성을 빠르게 파악하는 보조 정보로 함께 둔다.
|
||||
|
||||
## 2026-06-05 v1.5.74 — 게시물 상세 광고는 목차와 분리한다
|
||||
|
||||
게시물 상세 오른쪽 사이드바는 본문 목차가 핵심 기능이므로 공통 사이드 광고와 함께 두면 폭과 클릭 맥락이 어색해질 수 있다. 따라서 공통 오른쪽 사이드 광고는 게시물 상세가 아닌 화면에만 유지하고, 게시물 상세에는 별도 왼쪽 사이드 광고 슬롯을 둔다. 인아티클 광고는 상단·하단 광고와 함께 쓰이므로 짧은 글에서는 생략하고, 충분히 긴 글에서만 본문 흐름을 해치지 않도록 1회 또는 최대 2회로 제한한다.
|
||||
|
||||
## 2026-06-05 v1.5.73 — 게시물 인아티클 광고는 충분히 긴 본문 40% 지점에 1회 삽입
|
||||
|
||||
게시물 상세에는 이미 본문 상단·하단 광고 슬롯이 있으므로 인아티클 광고는 너무 자주 노출하지 않는다. 공개 렌더러가 편집 모드가 아닐 때만 광고 코드를 받고, 본문 블록이 충분히 길며 일반 문단 후보가 여유 있게 있을 때 전체 블록 40% 근처 문단 뒤에 한 번 삽입한다. 짧은 글이나 문단 후보가 부족한 글에서는 광고를 생략해 본문 흐름과 상하단 광고 간격을 유지한다.
|
||||
|
||||
## 2026-06-05 v1.5.72 — 메인 인피드 광고는 클라이언트에서 한 번 무작위 배치
|
||||
|
||||
메인 Latest 목록 사이 광고는 매번 같은 위치보다 자연스럽게 보이도록 무작위 삽입을 허용한다. 다만 SSR 단계에서 무작위 값을 만들면 서버 HTML과 클라이언트 hydration 결과가 달라질 수 있으므로, 브라우저 마운트 이후 게시물 사이 한 지점을 한 번 산출해 삽입한다. 광고는 첫 항목 앞이나 마지막 항목 뒤가 아니라 게시물 사이에만 들어가도록 제한한다.
|
||||
|
||||
## 2026-06-05 v1.5.71 — 화면 위치 광고 슬롯은 사이트 코드와 분리 관리
|
||||
|
||||
사이트 검증·공통 스크립트용 사이트 코드 카드와, 실제 화면 위치에 삽입되는 광고 단위를 분리했다. 이유는 ads.txt/head/footer는 전역 설정이고, 메인 피드·사이드바·게시물 본문 상단·하단 광고는 위치별 표시 여부와 레이아웃 책임이 다르기 때문이다. 광고 HTML은 관리자 신뢰 콘텐츠로 저장하되, 빈 값은 DOM을 만들지 않고 클라이언트에서만 삽입해 AdSense 스크립트 중복 실행 가능성을 줄인다.
|
||||
|
||||
## 2026-06-05 v1.5.70 — 라이브 멀티라인 Enter는 DOM 조각 삽입 대신 텍스트 값을 갱신한다
|
||||
|
||||
콜아웃·인용 본문은 `white-space: pre-wrap`인 plain text contenteditable로 관리한다. Range에 직접 텍스트 노드를 삽입하면 줄바꿈 직후 브라우저 IME 조합 위치가 불안정해져 한글 첫 글자가 자모로 분리될 수 있다. 멀티라인 Enter는 전체 텍스트 값을 기준으로 선택 범위를 `\n`으로 교체하고 커서를 텍스트 오프셋으로 다시 배치한다. 또한 Selection Bridge의 Range 교차 판정을 바로잡고, 콜아웃·인용 전체 선택 삭제는 블록 자체가 아니라 빈 본문 줄을 남기도록 한다.
|
||||
|
||||
## 2026-06-05 v1.5.69 — 멀티라인 라이브 편집은 끝 줄바꿈을 보존한다
|
||||
|
||||
인용·콜아웃 본문은 하나의 contenteditable 안에서 여러 원본 줄을 대표한다. 이 값 읽기에서 끝 줄바꿈을 잘라내면 마지막 줄에서 Enter를 눌러도 모델에는 새 줄이 남지 않고, 삭제·병합 같은 구조 변경 뒤에는 포커스 중인 오래된 DOM이 blur 때 다시 저장될 수 있다. 멀티라인 plain text 편집은 끝 줄바꿈을 보존해 읽고, 줄 삭제·병합 전에는 stale blur 커밋을 잠시 차단해 부모 모델 갱신이 우선되게 한다.
|
||||
|
||||
## 2026-06-05 v1.5.68 — Shift+위/아래 선택은 커서 위치 기준으로 확장한다
|
||||
|
||||
라이브 편집은 블록별 contenteditable 구조라 단일 줄 문단 안에서 브라우저 기본 Shift+위/아래가 다음 블록으로 자연스럽게 이어지지 않는다. 블록 끝에서만 확장하는 방식은 textarea의 줄 선택 경험과 달라 실제 글쓰기에서 실패처럼 느껴지므로, 단일 줄 블록에서는 커서 위치와 상관없이 Selection Bridge가 인접 블록의 같은 열로 선택을 확장하도록 바꾼다. 연속 확장 시에는 `sourceLine`이 아니라 Selection의 focus 노드가 속한 편집 요소를 현재 블록으로 사용한다.
|
||||
|
||||
## 2026-06-05 v1.5.67 — 라이브 교차 선택 삭제는 DOM이 아니라 마크다운을 갱신한다
|
||||
|
||||
교차 블록 선택은 DOM에는 보이지만 각 contenteditable의 키 처리만으로는 삭제가 반영되지 않는다. `lib/markdown-live-selection.js`가 선택 범위를 원본 줄·편집 본문 오프셋으로 변환하고, `ContentMarkdownRenderer`가 `content-replace`로 상위 에디터 본문을 한 번에 갱신한다. Shift+방향키 확장은 줄 범위 탐색과 선택 포커스 경계 판별을 보강하고, Shift 조합 시 블록 이동 단축키와 충돌하지 않게 분리한다.
|
||||
|
||||
## 2026-06-05 v1.5.66 — 라이브 선택은 블록 편집 구조를 유지한 채 브리지로 이어준다
|
||||
|
||||
라이브 모드를 단일 contenteditable 문서로 바꾸면 Enter·IME·블록 옵션·카드형 블록 편집과 충돌한다. 대신 각 블록 편집기는 유지하고 `ContentMarkdownRenderer`가 Selection API로 Shift 범위 확장과 단계적 전체 선택을 중개한다.
|
||||
|
||||
## 2026-06-05 v1.5.65 — IME 확정 Enter의 keyup 경로도 처리한다
|
||||
|
||||
일부 브라우저·한글 IME 조합에서는 마지막 글자 확정에 Enter를 쓰면 `keydown`이 contenteditable까지 전달되지 않고 `compositionend` 뒤 `keyup`만 남는다. 이 경우 기존 pending 방식만으로는 줄바꿈 동작을 예약할 수 없어 Enter를 한 번 더 눌러야 했다. 조합 종료 직후 짧은 시간 안에 들어온 Enter `keyup`도 같은 물리 키 입력으로 보고, 현재 Enter 모드에 맞는 블록 동작을 한 번만 실행한다.
|
||||
|
||||
## 2026-06-05 v1.5.64 — 텍스트 선택은 블록 이동보다 우선한다
|
||||
|
||||
라이브 편집은 블록 단위 contenteditable이 여러 개이므로 문서 전체를 한 번에 드래그 선택할 수는 없다. 다만 각 편집 영역 안에서는 Shift+방향키·전체 선택이 반드시 동작해야 한다. 방향키 블록 이동 단축키가 Shift 조합까지 가로채면 본문 편집이 망가지므로, 선택 제스처일 때는 커스텀 키 처리를 건너뛴다.
|
||||
|
||||
## 2026-06-05 v1.5.63 — 라이브 편집 IME Enter 처리는 공통 컴포넌트에서 통일한다
|
||||
|
||||
한국어 입력 중 Enter는 브라우저와 IME에 따라 글자 조합 확정 이벤트와 키 입력 이벤트 순서가 다르게 들어온다. 이를 블록별 컴포넌트에서 따로 처리하면 문단·목록·토글 제목·멀티라인 본문 사이의 동작이 다시 달라질 수 있다. 라이브 편집의 모든 텍스트 블록이 `ContentMarkdownEditableInline`을 거치므로, 조합 중 Enter를 공통 pending 동작으로 저장하고 `compositionend` 직후 각 Enter 모드의 실제 동작을 실행하도록 통일한다.
|
||||
|
||||
## 2026-05-26 v1.5.62 — Enter 분리 직후 포커스 중 DOM을 modelValue에 맞춘다
|
||||
|
||||
문단 Enter 분리는 마크다운을 먼저 갱신하고 Vue가 `modelValue`를 줄인 뒤에도, 포커스 중인 contenteditable은 병합 직전의 긴 문자열을 DOM에 남긴다. 이 상태에서 blur가 나가면 통째 줄이 다시 커밋되어 아래 줄이 복제된 것처럼 보인다. 분리·병합 직후에는 `modelValue`와 DOM을 강제 동기화하고, 구조 변경 직후의 stale blur 커밋은 차단한다.
|
||||
|
||||
## 2026-05-26 v1.5.61 — 멀티라인 포커스 시 빈 줄은 편집 영역 전체를 비우지 않는다
|
||||
|
||||
콜아웃·코드·토글 본문은 하나의 contenteditable이 `data-source-line`~`data-source-line-end`로 여러 원본 줄을 대표한다. 소스·라이브 전환 후 `focusEditableAtLine`은 대상 원본 줄이 비어 있으면 단일 줄 편집기처럼 DOM을 비우는데, 범위의 첫 줄이 비어 있을 때는 멀티라인 편집기 전체가 지워져 blur 커밋으로 마크다운까지 유실된다. 빈 줄 초기화는 단일 줄 편집기에만 적용하고, 줄 범위가 2줄 이상인 편집기는 해당 원본 줄 위치로 커서만 둔다.
|
||||
|
||||
## 2026-06-04 v1.5.54 — 콜아웃 제목 옵션과 기본 아이콘 미사용
|
||||
|
||||
콜아웃은 본문 시작 전 시각 신호를 독립적으로 보여주는 편이 여러 줄 본문에서 정렬이 안정적이다. 따라서 라이브·공개 렌더링을 아이콘·제목 헤더와 본문 영역으로 분리하고, 제목은 기존 콜아웃 선언부에 `title` 옵션으로 저장한다. 새 콜아웃은 본문 작성 부담을 줄이기 위해 기본 아이콘 표시를 끄고, 필요할 때 오른쪽 블록 설정 패널에서 켜도록 한다.
|
||||
|
||||
## 2026-06-04 v1.5.47 — RSS 썸네일은 피드 XML에 명시한다
|
||||
|
||||
게시물 상세 페이지에는 대표 이미지 기반 `og:image`가 있지만, RSS 리더가 게시물 링크를 다시 크롤링해 OG 이미지를 읽는지는 리더마다 다르다. 피드 구독 화면에서 썸네일 노출을 더 안정적으로 만들기 위해 RSS item에 Media RSS 확장인 `media:thumbnail`과 `media:content`를 직접 포함한다. 저장된 이미지 경로가 상대 경로일 수 있으므로 피드 생성 시 사이트 URL 기준 절대 URL로 변환한다.
|
||||
|
||||
## 2026-06-04 v1.5.46 — 콜아웃과 인용 팔레트는 하나로 맞춘다
|
||||
|
||||
콜아웃과 인용은 모두 작성자가 본문 안에서 특정 문맥을 강조하는 블록이므로 배경색 선택지가 서로 다르면 오른쪽 설정 패널의 의미가 흐려진다. 분홍은 빨강과 역할이 겹치고 실제 팔레트 변경 후에도 남아 있던 선택지라 제거하고, 두 블록 모두 같은 6색 팔레트를 사용한다. 라이브 편집의 방향키 이동은 옵션 선언 줄처럼 화면에 직접 편집 영역이 없는 줄을 건너뛰어야 하므로, 탐색 대상은 실제 contenteditable 줄과 카드형 블록으로 제한한다. 작은 화면의 게시물 설정 패널은 문서를 밀어내지 않는 고정 오버레이로 두어 본문 폭을 압축하지 않게 한다.
|
||||
|
||||
## 2026-06-04 v1.5.45 — 라이브 작성 블록 옵션도 오른쪽 패널로 통일한다
|
||||
|
||||
소스 모드에서는 커서가 옵션을 가진 블록 안에 들어가는 것만으로 오른쪽 설정 패널이 열리는데, 라이브 모드 콜아웃은 별도 모달을 열어 같은 기능을 다른 방식으로 제공하고 있었다. 작성자가 모드에 따라 다른 조작을 기억하지 않도록 콜아웃과 인용 블록 모두 라이브 영역에 포커스가 들어오면 오른쪽 패널을 쓰도록 통일한다. 인용 색상은 새 팔레트 기준으로 회색을 기본값으로 두고, 빨강과 역할이 겹치는 분홍은 선택지에서 제거한다.
|
||||
|
||||
## 2026-06-02 v1.5.43 — RSS는 공개 발행글 피드로만 제공한다
|
||||
|
||||
RSS 리더는 공개 화면을 구독하는 용도이므로 멤버십, 비공개, 초안, 아직 발행 시간이 오지 않은 예약 글은 피드에 포함하지 않는다. 호환성을 위해 대표 경로 `/rss.xml`과 흔히 쓰는 `/feed.xml`, `/rss` 별칭을 함께 제공하되, 관리자 SNS 프리셋은 표준적인 `/rss.xml`을 기본 주소로 둔다.
|
||||
|
||||
## 2026-06-02 v1.5.41 — SNS 편집은 아이콘과 주소만으로 충분하다
|
||||
|
||||
FOLLOW 영역은 아이콘 버튼 목록이므로 운영자가 직접 보는 편집 화면에서도 핵심 입력은 아이콘과 주소다. 이름은 접근성 라벨로 내부에서 프리셋명을 쓰면 충분하고, 별도 입력칸은 레이아웃을 복잡하게 만든다. 저장값은 JSONB 배열이어야 하므로 기존 문자열 저장값은 마이그레이션으로 복구하고, 저장 쿼리도 명시적으로 JSONB 배열로 캐스팅한다.
|
||||
|
||||
## 2026-06-02 v1.5.40 — SNS 아이콘은 프리셋과 직접 SVG를 함께 둔다
|
||||
|
||||
SNS 채널은 서비스 유행에 따라 계속 바뀌므로, Facebook·LinkedIn 같은 고정 필드만 두면 운영자가 쓰지 않는 항목과 새로 필요한 항목 사이에서 계속 코드 수정이 필요해진다. 기본 아이콘은 프리셋으로 빠르게 선택하게 두되, 프리셋에 없는 서비스는 관리자가 SVG 아이콘을 직접 붙여 넣어 같은 목록 구조로 저장하도록 했다. URL은 운영자가 `https://`를 생략해도 정상 링크로 보정해 저장 실패처럼 보이는 혼란을 줄인다.
|
||||
|
||||
## 2026-06-02 v1.5.39 — SNS 링크는 고정 필드보다 목록형이 맞다
|
||||
|
||||
오른쪽 사이드바 FOLLOW 영역은 사이트마다 사용하는 채널이 크게 다르다. Facebook이나 LinkedIn처럼 쓰지 않는 채널을 고정 필드와 기본 아이콘으로 계속 노출하면 실제 운영 화면과 맞지 않으므로, 사이트 설정에는 아이콘 프리셋과 주소의 목록을 저장한다. 주소가 있는 항목만 공개 화면에 렌더링해 FOLLOW 영역 자체가 운영자가 설정한 채널만 반영하도록 했다.
|
||||
|
||||
## 2026-06-02 v1.5.38 — 어나운스 바는 운영자가 브랜드 톤에 맞춘다
|
||||
|
||||
어나운스 바는 모든 공개 화면 상단에 노출되는 안내 요소라 프리셋 몇 개만으로는 사이트별 브랜드 톤을 맞추기 어렵다. 브랜드 컬러처럼 hex 색상 입력을 허용하되, 텍스트 대비색은 배경 밝기에 따라 자동 계산해 가독성을 유지한다. 문구 정렬은 중앙과 왼쪽 두 가지로 제한해 닫기 버튼이 오른쪽 끝에 남는 기존 구조를 유지하면서도 공지 성격에 맞게 배치할 수 있게 했다.
|
||||
|
||||
## 2026-06-02 v1.5.37 — 블록 옵션은 본문 옆 설정 패널로 모은다
|
||||
|
||||
콜아웃, 코드 블록, 토글은 본문 안에서 직접 편집할 수 있지만 옵션 조작 위치가 블록마다 다르면 작성 흐름이 끊긴다. 인용문 배경색처럼 커서가 놓인 블록을 오른쪽 패널에서 조정하도록 통일하면, 본문 텍스트와 구조 옵션을 분리해 다룰 수 있다. 옵션은 별도 메타 저장소를 만들지 않고 각 fenced 블록의 선언 줄에 반영해 Markdown 백업·Import 흐름과도 계속 호환되게 둔다.
|
||||
|
||||
## 2026-06-02 v1.5.36 — 포인트 컬러는 테마 파일이 아니라 운영 설정이다
|
||||
|
||||
사용자 화면의 활성 네비게이션, TOC, 댓글 버튼은 사이트의 브랜드 인상을 만드는 공통 강조 요소다. 코드에 고정된 오렌지색을 각 컴포넌트에서 따로 바꾸면 운영자가 브랜드를 바꾸기 어렵고 누락도 생기므로, 사이트 설정의 `brand_color`를 공개 앱 루트의 `--site-accent` CSS 변수로 주입한다. 기존 색상은 기본값으로 유지해 저장값이 없거나 마이그레이션 전 환경에서도 같은 화면을 보여 준다.
|
||||
|
||||
## 2026-06-02 v1.5.35 — 유입 분석은 축약 집계로 시작한다
|
||||
|
||||
관리자 대시보드는 방문자가 어디서 들어왔는지, 어떤 기기로 보는지, 검색 키워드가 있는지 정도를 빠르게 파악할 수 있어야 한다. 다만 원문 referrer나 IP를 오래 저장하면 운영 부담과 개인정보 리스크가 커지므로, 페이지뷰 요청 시 서버가 즉시 검색·SNS·직접·기타, 디바이스·OS, 키워드로 축약해 일별 집계만 남긴다. 검색 키워드는 검색엔진이 referrer query를 전달한 경우에만 수집되며, 숨겨진 키워드는 소급하거나 추정하지 않는다.
|
||||
|
||||
## 2026-06-02 v1.5.34 — 사이트 검증 코드는 설정으로 관리한다
|
||||
|
||||
AdSense의 `ads.txt`나 헤더 검증 스크립트는 테마 파일을 직접 고치기보다 운영 설정에서 바꾸는 편이 안전하다. 그래서 사이트 설정에 ads.txt, 헤더 코드, 푸터 코드를 별도 카드로 두고, 공개 HTML 응답과 루트 `/ads.txt`에서만 반영한다. 관리자 화면과 API 응답에는 삽입하지 않아 관리 도구가 외부 광고·검증 스크립트에 영향을 받지 않게 했다. gethomepage 연동은 아직 표시 항목이 확정되지 않았으므로 기존 통계 집계에서 바로 만들 수 있는 오늘 방문자·페이지뷰·현재 접속자·평균 체류시간을 반환하는 커스텀 API 틀로 시작한다.
|
||||
|
||||
## 2026-06-02 v1.5.33 — 완료 상태는 조용히 둔다
|
||||
|
||||
최근 내보내기 작업에서 준비 완료는 이미 다운로드 가능한 파일 목록으로 충분히 드러난다. 완료 배지를 계속 표시하면 정리된 다운로드 카드 안에서 불필요한 시각 요소가 되므로, 대기·생성·실패처럼 주의가 필요한 상태에서만 배지를 보여 준다.
|
||||
|
||||
## 2026-06-02 v1.5.32 — 완료된 백업은 선택 다운로드만 남긴다
|
||||
|
||||
내보내기 작업이 준비 완료된 뒤에는 진행도와 요청 조건보다 어떤 ZIP을 받을지가 더 중요하다. 한 파일만 있을 때도 일괄 다운로드와 개별 다운로드 버튼이 동시에 보이면 행동이 중복되어 보이므로, 완료 작업은 만료일만 남기고 파일 체크 선택과 선택 파일 다운로드로 정리한다. 진행도는 대기·생성 중 작업의 상태 확인용으로만 표시한다.
|
||||
|
||||
## 2026-06-02 v1.5.31 — 백업 요청과 다운로드 결과는 분리해서 보여 준다
|
||||
|
||||
내보내기 설정 카드는 새 백업 작업을 만드는 입력 영역이고, 최근 작업 목록은 이미 생성되었거나 생성 중인 결과 영역이다. 두 흐름을 같은 카드 안에 두면 접기·펼치기 상태와 다운로드 상태가 한 덩어리처럼 보이므로, 최근 작업은 작업이 있을 때만 별도 카드로 표시해 요청 설정과 결과 확인을 분리한다.
|
||||
|
||||
## 2026-06-02 v1.5.30 — 내보내기와 가져오기는 서로 다른 작업이다
|
||||
|
||||
게시물 백업 생성과 백업 ZIP 복원은 사용 빈도와 위험도가 다르다. 특히 가져오기는 거의 쓰지 않지만 한 번 실행하면 게시물이 새로 생성되므로, 파일 선택 즉시 실행하지 않고 `적용` 버튼을 명시적으로 누르게 했다. 설정 화면의 기본 철학도 현재 상태를 먼저 보여 주고 조작은 버튼 뒤에 숨기는 쪽이므로, 내보내기와 가져오기를 독립 카드로 나누고 각 상세 입력은 아코디언처럼 펼쳐지게 정리한다.
|
||||
|
||||
## 2026-06-02 v1.5.29 — 라이트·다크 커버는 각각 확인 가능해야 한다
|
||||
|
||||
홈 커버 이미지는 라이트·다크 테마에서 서로 다른 시각 결과를 만들 수 있다. 하나의 `HomeHero` 미리보기만 두면 현재 OS나 테마 상태에 따라 한쪽 이미지를 확인하기 어렵기 때문에, 관리자 설정에서는 라이트모드와 다크모드를 상하 개별 프리뷰로 항상 보여 준다. 아직 실제 동작이 없는 타임존 설정은 노출하지 않고, 로고·URL·저작권은 포괄적인 `사이트 정보`로 이름을 정리한다.
|
||||
|
||||
## 2026-06-02 v1.5.28 — Import는 부분 누락을 경고로 남긴다
|
||||
|
||||
Export ZIP을 Obsidian에서 열고 편집한 뒤 다시 Import할 수 있어야 하므로, 태그 frontmatter는 inline 배열뿐 아니라 Obsidian이 흔히 쓰는 블록 배열도 읽는다. 또한 일부 이미지나 파일이 ZIP에서 누락된 경우 전체 Import를 막으면 나머지 게시물 복구까지 실패하므로, 게시물 생성은 계속 진행하고 누락 자산은 관리자 화면 경고로 확인하게 한다.
|
||||
|
||||
## 2026-06-02 v1.5.27 — Import 1차는 덮어쓰기보다 안전한 추가 가져오기
|
||||
|
||||
Export ZIP은 백업 복구와 다른 환경으로의 이관에 모두 쓰일 수 있다. 같은 슬러그가 이미 있는 운영 DB에서 Import가 기존 글을 바로 덮어쓰면 복구보다 손상 위험이 커지므로, 1차 Import는 모든 게시물을 새 글로 추가하고 슬러그 충돌 시 `-2`, `-3` 접미사를 붙이는 정책으로 둔다. ZIP 내부 자산은 원래 URL을 믿지 않고 새 업로드 저장소로 다시 복사해 본문 경로를 새 `/uploads/...` URL로 재매핑한다.
|
||||
|
||||
## 2026-06-02 v1.5.26 — Import/Export 설정은 필요할 때만 펼친다
|
||||
|
||||
게시물 Import/Export는 운영자가 매일 조작하는 기본 설정이 아니라 필요할 때 요청하거나 다운로드하는 백업 도구다. 상세 범위와 분할 설정이 항상 카드 상단을 크게 차지하면 다른 설정을 훑는 흐름이 무거워지므로, 기본 화면은 `Export 요청`과 `Import 하기` 진입 버튼, 그리고 요청된 작업의 다운로드 상태 중심으로 줄였다. Export 조건 입력은 버튼을 눌렀을 때만 펼치고, 아직 실제 가져오기 API가 연결되지 않은 Import는 접힌 안내 패널로만 둔다.
|
||||
|
||||
## 2026-06-01 v1.5.25 — Export 분할은 용량 기준이 우선이다
|
||||
|
||||
기존 100개 단위 분할은 대략적인 안내값으로는 충분하지만, 게시물마다 첨부 자산 크기가 다르면 실제 ZIP 용량을 예측하기 어렵다. Export 계획은 게시물 본문 크기와 내부 업로드 자산 크기를 추산해 목표 ZIP 용량을 넘기기 전에 새 파일로 나누도록 바꿨다. ZIP당 최대 게시물 수는 용량 기준을 대신하는 규칙이 아니라 너무 많은 작은 글이 한 파일에 몰리는 상황을 막는 안전 상한으로만 남겼다. 실패 원인은 재시도 여부를 판단하는 운영 정보이므로 작업 카드에서 확인할 수 있게 별도 상세 로그로 저장한다.
|
||||
|
||||
## 2026-06-01 v1.5.24 — Export는 완료 후에도 관리 가능한 작업이어야 한다
|
||||
|
||||
대용량 Export는 파일 생성이 끝난 뒤에도 운영자가 여러 ZIP을 안정적으로 내려받고, 실패한 지점부터 다시 이어가고, 오래된 산출물을 치울 수 있어야 한다. 브라우저 일괄 다운로드는 준비 완료 파일을 순차로 요청하는 방식으로 두어 중간에 막혀도 개별 파일 버튼으로 이어받을 수 있게 했다. 실패 작업 재시도는 이미 생성된 ZIP을 유지하고 나머지 분할 파일만 다시 대기 상태로 되돌린다. 만료된 완료·실패 작업은 목록 조회나 새 요청 시 자동 삭제해 100일 보존 정책이 실제 저장 공간 정리로 이어지게 했다.
|
||||
|
||||
## 2026-06-01 v1.5.23 — Export는 필요한 기간만 꺼낼 수 있어야 한다
|
||||
|
||||
전체 백업은 최초 백업이나 큰 이관에는 필요하지만, 운영 중 반복 백업에서는 같은 게시물과 자산을 계속 다시 묶어 시간과 저장 공간을 낭비한다. Export 요청에 전체·특정년·특정월·직접 지정 날짜 범위를 추가해 필요한 기간만 백업할 수 있게 했다. 게시물 기준일은 발행일이 있으면 `published_at`, 없으면 `created_at`으로 판단한다. 생성 완료된 백업은 사용자가 바로 삭제할 수 있어야 서버 업로드 볼륨을 관리하기 쉽기 때문에, 완료·실패 작업 삭제 API도 함께 둔다.
|
||||
|
||||
## 2026-06-01 v1.5.22 — Export는 실제 파일 생성 단계로 연결
|
||||
|
||||
Export 작업이 대기열과 진행도 표시까지만 존재하면 관리자는 같은 버튼을 반복해서 누르기 쉽고, 실제 백업 파일도 받을 수 없다. 이번 단계에서는 요청 직후 서버 프로세스가 대기 작업을 백그라운드로 실행해 분할 ZIP을 만들고, 준비 완료된 파일만 다운로드할 수 있게 했다. 내부 `/uploads` 자산은 ZIP 내부의 `images/` 또는 `files/` 폴더로 복사하고 Markdown 참조는 상대 경로로 바꿔, 원본 서버 URL이 사라져도 Obsidian에서 백업을 열 수 있는 형태를 우선했다. 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼은 잠가 중복 작업 생성을 막는다.
|
||||
|
||||
## 2026-06-01 v1.5.21 — Export 작업은 숫자로 진행 여부를 확인
|
||||
|
||||
Export 작업이 대기열에만 보이면 관리자는 실제로 서버가 일하고 있는지 알 수 없다. 다운로드가 아직 연결되지 않았더라도 작업 레코드에는 전체 게시물 수 대비 처리된 게시물 수, 현재 분할 파일 순번, 진행 메시지, 시작 시각을 저장할 수 있어야 한다. 설정 화면은 진행 중 작업이 있을 때 주기적으로 목록을 새로고침하고 `1201 / 30002` 같은 숫자와 진행률 바를 보여 주도록 했다. 이 기반은 v1.5.22의 zip 생성 워커에서 실제 값 갱신에 사용한다.
|
||||
|
||||
## 2026-06-01 v1.5.20 — Export 구현은 작업 등록부터 단계적으로 연결
|
||||
|
||||
대용량 Export는 한 번에 UI, zip 생성, 이메일, 다운로드까지 붙이면 실패 지점 파악이 어렵다. 먼저 `post_export_jobs`와 `post_export_files`로 요청과 분할 파일 계획을 영속화하고, 관리자 설정 화면에서는 이 계획을 확인할 수 있게 했다. 실제 zip 생성 워커와 다운로드 API는 같은 작업/파일 레코드를 기준으로 이어 붙이면 되므로 이후 단계에서 재시도·만료·이메일 알림을 독립적으로 구현할 수 있다.
|
||||
|
||||
## 2026-06-01 v1.5.19 — 대용량 Export는 요청과 다운로드를 분리
|
||||
|
||||
게시물이 수만 개까지 늘어나면 관리자가 Export 버튼을 누르는 순간 모든 게시물과 자산을 하나의 zip으로 만드는 방식은 요청 타임아웃, 메모리 사용량, 브라우저 다운로드 실패 가능성이 크다. Export는 요청 즉시 파일을 내려주는 기능이 아니라 백그라운드 작업으로 분리한다. 서버는 게시물 개수나 산출 용량 기준으로 여러 zip을 만들고, 준비가 끝나면 이메일로 알린다. 관리자 화면은 각 분할 파일을 독립적으로 다운로드할 수 있게 하며, 일괄 다운로드는 브라우저에서 순차 실행해 중간 실패 시 해당 범위부터 다시 받을 수 있게 한다. 산출물은 백업 생성물이라 서버 용량을 계속 차지하므로 최대 100일 후 만료·삭제한다.
|
||||
|
||||
## 2026-06-01 v1.5.18 — 게시물 백업은 URL이 아닌 로컬 자산 번들
|
||||
|
||||
게시물 Export는 다른 도구로 읽는 이동성뿐 아니라, 원본 서버나 `/uploads` URL이 사라져도 복원 가능한 백업이어야 한다. 따라서 Markdown 본문이 기존 URL을 그대로 참조하는 방식은 백업 요구에 맞지 않다. 게시물마다 폴더를 만들고 `제목.md`와 함께 `images/`, `files/`에 내부 자산을 복사하며, Markdown 안의 내부 업로드 URL은 상대 경로로 재작성한다. Import는 이 구조를 역으로 읽어 자산을 미디어 저장소에 다시 올리고 본문 경로를 새 업로드 URL로 매핑한다.
|
||||
|
||||
## 2026-06-01 v1.5.17 — 설정 토글 읽기 상태와 게시물 이동 포맷
|
||||
|
||||
사이트 설정 카드는 `편집`을 누르기 전에는 읽기 전용이다. 토글이 ON인 경우에도 일반 활성 스위치와 같은 검정 톤이면 조작 가능한 컨트롤처럼 보이므로, 읽기 모드 토글은 낮은 대비와 비활성 커서를 사용해 현재 값은 보여 주되 편집 전에는 조작할 수 없음을 드러낸다.
|
||||
|
||||
게시물 Import/Export는 Obsidian 로컬 보관과 재가져오기를 함께 고려해야 하므로, 1차 포맷은 HTML이나 JSON보다 범용성이 높은 Markdown 파일로 둔다. 제목·태그·상태·발행일·대표 이미지·SEO 같은 CMS 메타데이터는 YAML frontmatter에 넣고, 본문은 기존 Markdown을 최대한 그대로 유지한다. 자산 백업 기준은 v1.5.18에서 게시물별 로컬 번들 구조로 정정했다.
|
||||
|
||||
## 2026-06-01 v1.5.16 — TOC를 읽기 위치 표시 장치로 확장
|
||||
|
||||
게시글 TOC는 클릭 이동만 지원하면 긴 글에서 현재 위치를 다시 파악하기 어렵다. 본문 스크롤 위치를 기준으로 현재 H1~H3 제목을 계산해 TOC 항목에 강조 상태를 주고, 항목이 많아 TOC 영역 안에서 넘칠 때는 활성 항목이 보이도록 내부 스크롤을 보정한다. 모바일에서는 TOC 자체를 숨기는 기존 결정을 유지해 본문 아래에 불필요한 긴 목차가 붙지 않게 한다.
|
||||
|
||||
댓글 작성 버튼은 빈 입력 상태에서 활성화되어도 서버 요청 전에 차단되기만 하므로 사용자에게 실행 가능한 동작처럼 보인다. trim 기준 입력값이 있을 때만 댓글·답글 등록 버튼을 활성화해 화면 상태와 실제 저장 조건을 맞춘다.
|
||||
|
||||
## 2026-05-27 v1.5.15 — 기본 사용자 아이콘 표시 범위 통일
|
||||
|
||||
헤더의 사용자 메뉴 버튼은 로그인 여부를 여는 진입점이기도 하므로 비로그인 상태에서 `?`를 표시하면 오류나 누락처럼 보인다. 아바타가 없는 로그인 회원과 비로그인 방문자 모두 같은 사람 아이콘을 쓰도록 통일해 버튼 의미를 명확하게 유지한다.
|
||||
|
||||
## 2026-05-27 v1.5.14 — 모바일 TOC와 미디어 일괄 작업 정리
|
||||
|
||||
게시글 TOC는 데스크톱 오른쪽 사이드바에서는 읽기 이동성을 높이지만, 모바일에서는 사이드바가 본문 아래로 내려가 읽기를 마친 뒤에야 보이므로 실효성이 낮다. 따라서 모바일 폭에서는 TOC를 숨기고 본문 흐름을 짧게 유지한다. 미디어 라이브러리는 폴더·검색·필터가 이미 운영 단위이므로, 전체 선택도 현재 표시 결과만 대상으로 두어 실수로 숨겨진 항목까지 선택하지 않게 했다. 선택 삭제는 기존 잠금 정책을 유지해 게시물·페이지나 회원 프로필에서 쓰이는 파일은 제외한다.
|
||||
|
||||
## 2026-05-27 v1.5.13 — TOC 이동감과 위치 보정
|
||||
|
||||
목차 링크를 기본 해시 이동에 맡기면 브라우저가 즉시 점프하고, 고정 헤더와 제목의 위치 관계에 따라 제목이 화면 위쪽에 붙거나 일부 가려질 수 있다. 게시글 TOC는 읽는 흐름을 돕는 장치이므로 클릭 이벤트를 직접 처리해 부드러운 스크롤로 이동하고, 제목 요소 자체에는 상단 헤더 높이와 여백을 반영한 scroll margin을 둔다.
|
||||
|
||||
## 2026-05-27 v1.5.12 — 게시글 상세 오른쪽 사이드바 TOC 전환
|
||||
|
||||
추천 사이트는 탐색 보조 영역이지만, 게시글을 읽는 순간에는 외부 이동보다 본문 안에서 빠르게 이동하는 편의가 더 중요하다. 따라서 게시글 상세에서는 오른쪽 사이드바의 Recommended 영역을 숨기고, 본문 H1~H3 제목에서 추출한 TOC를 같은 위치에 표시한다. 본문 렌더러와 TOC가 같은 앵커 ID 생성 규칙을 공유하도록 공통 유틸을 두어 제목 중복이나 한글 제목에서도 링크가 안정적으로 맞게 했다.
|
||||
|
||||
## 2026-05-27 v1.5.11 — 멤버 상세 보기/수정 모드 분리
|
||||
|
||||
멤버 상세 화면은 운영자가 상태를 확인하는 시간이 더 많고, 권한·이메일·관리자 노트 같은 값은 실수로 바뀌면 영향이 크다. 따라서 기존 회원 상세는 먼저 읽기 전용 상태만 보여주고, 명시적으로 `수정하기`를 누른 뒤에만 편집 컨트롤을 노출한다. 저장 버튼도 실제 변경이 생긴 뒤에만 활성화해 “저장할 내용이 있는지”를 버튼 상태로 알 수 있게 했고, 저장 결과는 다른 관리자 화면과 같은 우측 상단 토스트로 통일한다.
|
||||
|
||||
## 2026-05-27 v1.5.10 — 권한 UI와 글 목록 스캔성 보정
|
||||
|
||||
권한 변경은 서버에서 거부되더라도 사용자가 바꿀 수 있는 것처럼 보이면 운영 실수와 혼란이 생긴다. 따라서 소유자 본인 강등, 관리자의 소유자·관리자 조작처럼 서버 규칙상 막히는 상황은 멤버 상세 화면에서도 셀렉트를 비활성화하고, 등급 변경 권한 자체가 없는 세션은 일반 텍스트로만 보여 준다. 글 목록은 필터만으로는 특정 글을 빠르게 찾기 어려우므로 검색을 추가하고, 대표 이미지는 제목 옆 작은 썸네일로만 보여 목록 높이를 키우지 않으면서 이미지 존재 여부를 알 수 있게 했다. 페이지 HTML 문서는 VS Code식 `!`+Tab 습관을 살려 빈 본문에서 기본 골격을 빠르게 채우도록 했다.
|
||||
|
||||
## 2026-05-27 v1.5.9 — 페이지 통계와 추천 사이트 메타데이터 확장
|
||||
|
||||
고정 페이지는 HTML 랜딩 페이지처럼 단독 URL로 쓰이기 때문에 게시물처럼 조회 추이를 볼 수 있어야 한다. 일반 Nuxt 페이지는 기존 클라이언트 통계 플러그인으로 pageSlug를 함께 보내고, 원문 HTML 문서 모드는 Nuxt 앱이 실행되지 않으므로 서버 미들웨어에서 GET 조회를 직접 기록한다. 추천 사이트는 URL 자체보다 운영자가 지정한 짧은 문구와 썸네일이 더 명확한 경우가 있으므로, 기존 파비콘 fallback은 유지하되 대체 텍스트와 썸네일 URL을 선택적으로 저장하게 했다.
|
||||
|
||||
## 2026-05-27 v1.5.8 — 소유자 권한 보호와 멤버 목록 등급 표시
|
||||
|
||||
소유자는 시스템을 복구할 수 있는 최상위 권한이므로 본인 계정을 관리자 이하로 직접 낮출 수 없게 한다. 기존의 마지막 소유자 보호는 동시에 여러 사용자가 권한을 바꾸는 상황을 막기 위한 장치로 유지하고, 이미 소유자가 0명이 된 개발 DB는 마이그레이션으로 가장 오래된 관리자 계정을 소유자로 되돌릴 수 있게 했다. 멤버 목록은 운영자가 등급을 빠르게 스캔해야 하므로 별도 열을 추가하지 않고 기존 상태 열에 등급을 먼저 보여주며, 활성 상태는 기본값이라 숨기고 비활성만 보조 상태로 표시한다.
|
||||
|
||||
## 2026-05-26 v1.5.7 — 페이지 형식 선택과 IP 기록 보정
|
||||
|
||||
페이지 형식 선택은 현재 모드와 관계없이 되돌릴 수 있어야 하므로 설정 패널에서 항상 표시한다. 일반 텍스트 모드는 HTML 문서가 아니어서 HTML 자산 업로드가 의미 없으므로 해당 업로드 UI는 HTML 문서 모드에서만 표시한다. 멤버 정보의 접속 IP는 프록시 뒤에서 기본 `getRequestIP`가 비어 저장될 수 있으므로, 로그인·회원가입·댓글 활동 등 회원 활동 기록에는 `x-forwarded-for`를 포함한 요청 IP 조회를 공통으로 사용한다.
|
||||
|
||||
## 2026-05-26 v1.5.6 — 멤버 권한 변경을 저장 액션과 서버 규칙으로 고정
|
||||
|
||||
멤버 등급은 접근 권한을 바꾸는 민감한 값이므로 셀렉트 변경 즉시 저장하면 사용자가 의도하지 않은 권한 변경이 발생할 수 있다. 따라서 멤버 상세 화면의 등급 변경은 다른 기본 정보처럼 저장 버튼을 눌렀을 때만 반영한다. 서버는 화면 상태와 무관하게 소유자·관리자만 권한을 변경할 수 있게 하고, 관리자는 다른 관리자나 소유자를 조작하지 못하며 소유자·관리자 등급도 부여하지 못하게 막는다. 마지막 소유자 보호는 트랜잭션 잠금 안에서 검사해 동시에 권한을 바꿔도 소유자가 사라지지 않도록 한다.
|
||||
|
||||
## 2026-05-26 v1.5.5 — 멤버십 공개 기준을 VIP 등급으로 고정
|
||||
|
||||
로그인 회원은 댓글 작성을 위해 필요한 기본 사용자 범위라서 멤버십 콘텐츠의 공개 기준으로 쓰기에는 너무 넓다. 따라서 `members` 상태 게시물은 단순 세션 존재가 아니라 `vip`, `admin`, `owner` 등급에만 노출되도록 바꾼다. `vip`는 관리자 권한은 없지만 멤버십 콘텐츠 접근 권한을 가진 등급이며, 관리자 멤버 상세 화면에서 직접 지정할 수 있게 했다.
|
||||
|
||||
## 2026-05-26 v1.5.4 — 게시물·페이지 공개 상태를 다시 확장
|
||||
|
||||
게시물은 초안·발행·예약만으로는 운영자가 숨겨야 하는 글과 회원에게만 보여야 하는 글을 구분하기 어렵다. 따라서 `private`는 공개 화면에서 완전히 숨기는 상태로 되살리고, `members`는 회원 전용 공개 상태로 추가한다. 이후 v1.5.5에서 이 회원 전용 공개 기준은 로그인 여부가 아니라 VIP 이상 등급 기준으로 확정했다.
|
||||
|
||||
고정 페이지도 운영 전이지만 HTML 랜딩 페이지를 붙여넣어 쓰는 구조라 공개 전 확인이나 비공개 보관 상태가 필요하다. 페이지는 게시물과 달리 멤버십 공개 요구가 아직 없으므로 `draft`, `published`, `private`만 두고, 공개 API와 HTML 문서 미들웨어는 `published`만 응답하게 정리한다.
|
||||
|
||||
## 2026-05-26 v1.5.3 — 고정 페이지 기본값을 HTML 랜딩 페이지 작성에 맞춤
|
||||
|
||||
고정 페이지는 운영에서 아직 본격 사용 전이고, 앞으로는 단일 랜딩 페이지 HTML을 붙여넣어 공개 URL에서 원문 HTML로 응답하는 용도가 중심이다. 따라서 새 페이지의 기본 형식은 HTML 문서 모드로 바꾸고, 기존 Markdown 모드는 실제 사용 가능성이 낮으므로 관리자 UI에서 `일반 텍스트`로 명확히 표시한다. 페이지 대표 이미지는 공개 원문 HTML 응답과 연결되지 않으므로 제거하고, HTML 안에서 필요한 이미지는 기존 미디어 업로드 API로 파일을 올린 뒤 URL을 현재 커서 위치에 삽입하는 방식으로 정리한다. 미디어 사용 여부는 페이지 본문 문자열 안에 저장된 URL도 검사하므로, HTML 코드에 업로드 URL이 포함되면 페이지 사용처로 추적된다.
|
||||
|
||||
## 2026-05-26 v1.5.2 — 페이지 작성 화면을 게시글 작성 화면과 통일
|
||||
|
||||
고정 페이지는 랜딩 페이지 작성 용도로 확장되므로 일반 관리자 폼보다 게시글 작성과 같은 집중형 전체 화면 에디터가 더 적합하다. 게시글과 페이지의 작성 화면이 다르면 저장 위치, 설정 위치, 본문 입력 방식이 매번 달라져 운영 피로가 커진다. 따라서 페이지 작성/수정도 상단 툴바와 오른쪽 접이식 설정 패널을 쓰고, 페이지 형식 선택과 URL·대표 이미지·삭제 액션은 설정 패널로 모은다. 기본 콘텐츠 입력도 게시글과 같은 Markdown-first 에디터를 사용해 작성 경험을 통일한다.
|
||||
|
||||
## 2026-05-26 v1.5.1 — 고정 페이지에 원문 HTML 문서 모드 추가
|
||||
|
||||
고정 페이지는 아직 운영에서 본격 사용 전이므로 구조를 크게 바꿀 수 있다. 랜딩 페이지처럼 한 주소에서 단일 `index.html` 문서를 보여주는 목적에는 Nuxt 컴포넌트 안의 `v-html`보다 서버에서 `text/html`로 원문을 응답하는 방식이 맞다. 따라서 페이지에는 `render_mode`를 두고, 기본 Markdown 모드는 기존 경로를 유지하되 `html_document` 모드만 서버 미들웨어가 `/pages/:slug` 요청을 가로채 저장된 HTML을 그대로 반환한다. 이 모드는 관리자만 저장하는 신뢰 콘텐츠를 전제로 한다.
|
||||
|
||||
## 2026-05-26 v1.5.0 — 글쓰기 태그 입력을 검색형 선택으로 정리
|
||||
|
||||
게시물 태그는 여러 개 저장되지만 관리자 글 목록에서는 운영자가 빠르게 분류를 읽는 것이 우선이므로 첫 번째 태그만 대표 태그로 표시한다. 글쓰기 화면은 직접 입력 흐름을 유지하되, 메인 태그처럼 이미 관리자가 등록한 태그는 다시 타이핑하지 않아도 되도록 오른쪽 트리거를 선택 드롭다운으로 바꾼다. 기존 태그는 이름과 슬러그 부분 일치로 추천해 `no` 입력만으로 `note` 같은 태그를 찾고, 방향키와 Enter로 추가할 수 있게 한다. 태그별 색상은 관리 화면에서 이미 운영자가 지정하는 분류 신호이므로 글쓰기 배지와 글 목록 대표 배지에도 같은 색상을 반영한다.
|
||||
|
||||
## 2026-05-22 v1.4.7 — 라이브 모드 인라인 마크다운 직렬화
|
||||
|
||||
라이브 편집 영역은 화면에 `<strong>`·`<em>` 등으로 표시되지만, blur 시 저장 경로가 `textContent`만 읽으면 `**`·`*` 마커가 빠진다. 문단 이동 시 이전 블록이 blur·commit 되므로 방향키만으로도 서식이 사라진 것처럼 보였다. `readEditableTextFromElement`가 DOM 인라인 노드를 마크다운으로 다시 직렬화하도록 수정한다.
|
||||
|
||||
인용 블록은 표준 `>` 문법을 유지하되, 첫 줄에 `> [!bg=yellow]`처럼 옵션 줄을 둘 수 있게 한다. 새 fenced block을 추가하지 않으면 기존 마크다운과 호환되고, 콜아웃에서 이미 쓰는 배경 프리셋을 공유할 수 있다. 소스에서 라이브로 전환할 때는 커서 줄에 포커스만 두고 스크롤하지 않는 경로가 있어 화면 위치가 어긋났으므로, 해당 전환에서는 대상 줄을 중앙에 가깝게 스크롤한다.
|
||||
|
||||
## 2026-05-22 v1.4.6 — 사이트 설정 이미지 저장 흐름 통일
|
||||
|
||||
관리자 사이트 설정은 섹션별 `편집` 후 `저장`으로 반영되는 컨셉이므로, 로고 업로드도 DB를 즉시 갱신하지 않고 업로드된 파일 URL만 폼에 반영한 뒤 기타 설정 저장 시 함께 저장하도록 정리한다. 홈 커버는 공개 라이트·다크 테마에 따라 이미지 톤이 크게 달라질 수 있어 기존 라이트 이미지를 기본값으로 유지하면서 다크 전용 URL을 별도 컬럼으로 추가한다. 다크 이미지가 없으면 기존 이미지로 fallback해 기존 설정과 공개 화면 동작을 유지한다.
|
||||
|
||||
## 2026-05-22 v1.4.5 — 게시물 작성자 기준 편집 링크
|
||||
|
||||
공개 게시글 상세의 편집 버튼은 단순히 “관리자 로그인 여부”가 아니라 실제 글쓴이인지로 판단해야 한다. 현재 운영은 관리자 1인 작성 전제지만, 멤버·권한 구조가 이미 분리되어 있으므로 게시물에 `author_id`를 명시해 현재 로그인 회원 ID와 비교하는 방식으로 정리한다. 기존 게시물은 owner/admin 계정이 정확히 1개일 때만 backfill해 잘못된 작성자 배정을 피하고, 새 게시물은 관리자 세션의 사용자 ID를 작성자로 저장한다.
|
||||
|
||||
## 2026-05-21 v1.4.2 — 관리자 레이아웃 라이트 테마 격리
|
||||
|
||||
공개 사이트의 라이트/다크 테마는 `html[data-theme]`와 CSS 변수로 전역에 적용된다. 같은 앱 안의 관리자 화면이 이 변수를 그대로 상속하면, 공개 화면을 다크모드로 둔 상태에서 관리자 네비게이션 입력처럼 별도 배경색을 명시하지 않은 폼 컨트롤이 어두운 색으로 바뀌어 관리 UI 가독성이 깨진다. 관리자 로그인은 별도 다크 인증 화면으로 유지하되, 로그인 이후 `admin-layout`은 운영 도구 성격에 맞춰 라이트 UI로 고정한다. 다만 글쓰기 에디터는 별도 입력 UX가 있으므로, 폼 컨트롤 `color-scheme` 재정의는 `admin-layout--light-controls`가 붙은 일반 관리자 화면에만 적용한다.
|
||||
|
||||
## 2026-05-21 v1.4.1 — 라이브 모드 임베드 즉시 프리뷰 전환
|
||||
|
||||
단독 URL 붙여넣기가 임베드 작성의 기본 흐름이 되면서 라이브 모드의 별도 URL 입력 카드는 같은 값을 다시 입력하게 만드는 중복 UI가 됐다. 임베드 블록은 즉시 실제 프리뷰로 보여주고, iframe 때문에 일반 텍스트처럼 커서를 둘 수 없는 문제는 프리뷰 래퍼 자체를 포커스 가능한 블록으로 만들어 삭제·아래 줄 추가 키보드 조작을 받도록 했다. 같은 문제가 업로드 비디오·오디오·파일 카드에도 적용되므로 공통 프리뷰 카드 동작으로 확장한다. iframe·audio 컨트롤 등이 내부 포커스를 가져갈 수 있으므로 hover/focus 삭제 버튼을 둔다. 선택된 프리뷰 카드에서 방향키가 페이지 스크롤로 빠지지 않도록 `ArrowUp`·`ArrowDown`은 이전/다음 편집 줄 포커스 이동으로 처리한다. 임베드 전용 왼쪽 핸들은 전체 블록 공통 정책이 아니므로 제거한다.
|
||||
|
||||
## 2026-05-21 v1.4.1 — 라이브 모드 제목 Enter 처리 보정
|
||||
|
||||
제목 블록에서 Enter를 누를 때 일반 블록 아래 삽입 로직만 타면 현재 제목 편집 값이 먼저 저장되지 않거나 다음 포커스가 원문 줄처럼 보이는 상태로 이어질 수 있었다. 제목 Enter는 제목 마크다운 줄과 아래 빈 줄을 한 번에 반영해, 제목은 유지하고 다음 빈 문단으로 자연스럽게 이동하도록 분리했다.
|
||||
|
||||
## 2026-05-21 v1.4.1 — 임베드 저장 형식 단독 URL 통일
|
||||
|
||||
단독 URL 한 줄을 자동 임베드로 해석하게 되면서 같은 외부 링크가 `https://...`와 `:::embed` fenced block 두 형식으로 새로 저장될 수 있었다. 작성자가 원본 마크다운을 읽을 때 혼선이 생기지 않도록 새 임베드 삽입, 라이브 편집, 레거시 블록 변환의 저장 형식은 단독 URL 한 줄로 통일하고, 기존 `:::embed` 콘텐츠는 렌더링 호환만 유지한다.
|
||||
|
||||
## 2026-05-21 v1.4.1 — 미디어 업로드 크기 한도 분리
|
||||
|
||||
관리자 게시물 미디어 업로드 API가 `MAX_FILE_SIZE`(기본 10MB) 하나만 쓰고 있어 동영상 업로드가 413으로 실패했다. 아바타·로고 등 이미지 전용 한도는 유지하고, `POST /admin/api/uploads`만 비디오·오디오·문서별 환경 변수(`MAX_VIDEO_FILE_SIZE` 등)로 검사하도록 분리했다. 에디터 미디어 모달에는 최대 용량 안내와 413 토스트를 추가했다.
|
||||
|
||||
## 2026-05-20 v1.4.0
|
||||
|
||||
### 미디어 선택과 단독 URL을 작성 흐름에 연결
|
||||
|
||||
비디오·오디오·파일 블록을 템플릿만 삽입하면 작성자가 업로드 URL을 직접 복사해야 하므로 이미지/갤러리와 UX가 맞지 않는다. 기존 미디어 모달을 확장해 파일 종류별 선택·업로드 후 fenced block URL과 표시 메타를 자동으로 채우도록 했다. 외부 임베드는 작성자가 `:::embed`를 기억하지 않아도 되도록 단독 `http(s)` URL 한 줄을 같은 임베드 블록으로 해석한다.
|
||||
|
||||
### 외부 임베드를 플랫폼별 표시 정책으로 분리
|
||||
|
||||
YouTube는 본문 폭 16:9 영상이 자연스럽지만 X/Twitter 공식 iframe은 내부 카드 최대 폭이 고정되어 있어 전체 폭 iframe으로 두면 오른쪽 공백이 커지고 내용이 잘릴 수 있다. X/Twitter와 Mastodon은 소셜 카드로 보고 좁은 폭 중앙 정렬을 적용한다. Mastodon은 인스턴스별 공개 게시물 URL 뒤에 `/embed`를 붙이는 표준 경로를 우선 사용하고, 공식 embed 스크립트와 같은 `postMessage` 높이 요청으로 긴 글이 잘리지 않게 한다. 다만 인스턴스 정책에 따라 iframe 표시가 실패할 수 있으므로 링크 fallback 정책은 유지한다.
|
||||
|
||||
### 미디어 Prose 블록을 fenced block 렌더링으로 연결
|
||||
|
||||
비디오·오디오·파일은 기존 이미지/갤러리처럼 업로드 URL을 본문 마크다운에 저장하는 방식이 관리와 이식성이 가장 단순하다. 따라서 `:::video`, `:::audio`, `:::file` fenced block을 추가하고, 표시용 메타는 `url=`, `title=` 같은 키값으로 둔다. 공개 화면은 카드형 컴포넌트가 렌더링하고, 관리자 에디터는 슬래시 명령으로 기본 템플릿을 삽입해 이후 업로드/선택 UI 확장과 분리한다.
|
||||
|
||||
## 2026-05-20 v1.3.7
|
||||
|
||||
### NAS 마이그레이션 명령을 npm 없는 호스트 기준으로 보정
|
||||
|
||||
NAS 운영 호스트는 Docker Compose만 있고 Node/npm이 없을 수 있다. 운영 DB 마이그레이션은 앱 빌드 도구가 아니라 배포 호스트에서 실행하는 운영 절차이므로, `npm run db:migrate:prod:*`만 안내하면 실제 NAS에서 막힌다. Docker Compose와 DB 컨테이너의 `psql`만 사용하는 `scripts/migrate-production-db.sh`를 추가해 호스트 npm 설치 여부와 무관하게 상태 확인, baseline, 미적용 SQL 실행을 처리하도록 했다.
|
||||
|
||||
## 2026-05-20 v1.3.6
|
||||
|
||||
### NAS 운영 마이그레이션 적용 이력 도입
|
||||
|
||||
운영 NAS에서 몇 번 SQL까지 적용했는지 파일명만으로 추적하면 누락과 중복 실행을 사람이 기억해야 한다. `schema_migrations` 테이블에 적용 완료 파일을 기록하고, 운영용 `db:migrate:prod`는 기록이 없는 파일만 실행하도록 했다. 이미 운영 중인 DB에는 과거 적용 기록이 없으므로, 기존 스키마가 감지되면 001부터 자동 실행하지 않고 baseline 기록을 요구해 데이터 변경 SQL의 의도치 않은 재실행을 막는다.
|
||||
|
||||
## 2026-05-20 v1.3.5
|
||||
|
||||
### 관리자 로그인·대시보드 차트·통계 보관 후속
|
||||
|
||||
v1.3.4 통계 확장 이후 운영에서 로그인 쿠키·클라이언트 번들·차트 조회 오류가 겹쳐 후속 정리가 필요했다. 세션 쿠키는 공통 유틸로 묶고, 통계 상수는 `analytics-shared`로 분리했다. 대시보드는 기간별 `trends` 차트와 접속자 목록 가독성을 맞췄다. 저장 용량은 일별 집계는 누적 원본으로 두고 방문자 해시만 32일 초과 시 정리한다.
|
||||
|
||||
## 2026-05-15 v1.1.18
|
||||
|
||||
### 에디터 미디어 UX·발행일·수정일 표시 설정
|
||||
|
||||
이미지 삽입을 미디어/업로드 버튼으로 나누면 워드프레스 대비 단계가 많아지므로, 툴바는 `이미지`·`갤러리`만 두고 모달에서 라이브러리(기본)와 업로드 탭을 전환한다. 본문 이미지 너비 옵션은 렌더·운영 복잡도 대비 사용 빈도가 낮아 툴바·블록 편집 UI에서 제거한다(기존 `{width=…}` 마크다운은 파싱만 유지). 발행일은 관리자 목록에서 시·분까지 보여 주고, 발행 후 수정이 있으면 `site_settings.show_post_updated_at`이 true일 때만 보조 줄로 수정 시각을 노출해 공개 상세와 동일한 정책을 쓴다.
|
||||
|
||||
## 2026-05-15 v1.1.16
|
||||
|
||||
### 게시물 상태 단순화·초안 첫 저장·발행 글 편집 UX
|
||||
|
||||
접근 권한별 공개 범위는 추후 별도 기능으로 두고, 지금은 `private`를 없애 모두 초안·발행(`published_at`으로 예약 여부 판별)만 쓴다. 제목 없이 저장이 막히던 문제는 신규에 임시 슬러그와 서버 측 빈 제목 `(제목 없음)` 보정으로 푼다. 이미 발행된 글에서 사이드바만 바꾸면 툴바가 `Publish`로 바뀌던 것은 폼 상태만 본 탓이므로, 서버에 반영된 게시 형태를 따로 두어 `Update`·자동 저장 여부를 맞춘다.
|
||||
|
||||
## 2026-05-15 v1.1.15
|
||||
|
||||
### 초안 서버 자동 저장·이탈 가드·목록 헤더
|
||||
|
||||
신규 글도 초안·비공개면 DB 행 없이 입력만 두면 복구가 불가능하므로 기존 글과 동일하게 디바운스 `POST`로 첫 행을 만든 뒤 편집 URL로 옮긴다. 초안·비공개는 이후에도 자동 저장되므로 내부 이동 시 미저장 확인 모달은 발행·예약처럼 자동 저장이 없는 경우에만 둔다. 디바운스 직후 이탈로 놓치는 한 번의 변경은 라우트 가드에서 타이머를 취소하고 즉시 `POST`/`PUT` 플러시로 보완한다. 글 목록은 Ghost류처럼 필터를 «새 글» 왼쪽에 붙여 한 눈에 조작 단위를 모은다.
|
||||
|
||||
## 2026-05-15 v1.1.14
|
||||
|
||||
### 관리자 글쓰기 툴바·저장 정책을 Ghost에 맞춤
|
||||
|
||||
로컬 자동 저장 복원은 저장된 DB 초안과 새 글 작성 UX가 충돌한다. 초안은 서버 디바운스 저장으로 `Draft`/`Saving...`/`Draft - Saved`를 맞추고, 발행·예약 글은 자동 저장 없이 `Update` 한 번에만 반영한다. 툴바는 상태별 `Publish`·`Update`·`Unpublish`·`Unschedule`과 `Published ↗`·`Scheduled` 툴팁으로 의도를 분명히 한다.
|
||||
|
||||
## 2026-05-15 v1.1.13
|
||||
|
||||
### 상단 메뉴 1뎁스·추천 사이트·파비콘 프록시
|
||||
|
||||
사이드바 상단 네비는 운영상 루트와 그 직속 자식만이면 충분하고, 그보다 깊은 트리는 편집·표시 모두 부담이 된다. 저장·공개 트리 조립·관리자 드래그에서 한 단계로 막는다. 우측 Recommended는 하드코딩 대신 `location=recommended` 평면 행으로 두어 메뉴 관리 한 화면에서 다루게 하고, 외부 링크는 호스트만 추출해 Google Favicon CDN URL을 쓰면 별도 스크래핑 없이 아이콘을 얻을 수 있다(내부 경로는 생략).
|
||||
|
||||
## 2026-05-15 v1.1.12
|
||||
|
||||
### 상단 메뉴 편집: 드롭 구역 시각 구분과 계층형 개요 번호
|
||||
|
||||
행 위·중·아래만으로는 형제 순서 이동과 부모 변경(하위 편입)이 한눈에 구분되기 어렵고, 평면 행 번호(2,3,4…)는 부모·자식 관계와 맞지 않아 혼란스럽다. 드래그 중에는 파란 끝선·앰버 링과 짧은 한글 캡션으로 의미를 고정하고, 개요 열은 `1`, `2.1`, `2.2`처럼 트리 깊이에 맞춘 표기로 바꾸며 라벨 들여쓰기를 키워 구조를 읽기 쉽게 한다.
|
||||
|
||||
## 2026-05-15 v1.1.11
|
||||
|
||||
### 관리자 상단 네비를 평면 드래그 편집으로 통일
|
||||
|
||||
`하위` 전용 추가 버튼과 중첩 테이블은 트리 깊이가 늘수록 조작이 어색하고 공개 사이드바와 다른 시각 계층을 준다. 항목은 모두 `상단 메뉴 추가`로 만든 뒤, 한 테이블에서 들여쓰기만으로 깊이를 보여 주고, 행의 위·가운데·아래 드롭 구역으로 형제 사이 끼움과 하위 편입을 나누면 Ghost류 아웃라이너와 비슷한 자유도를 유지하면서 UI는 단순해진다.
|
||||
|
||||
## 2026-05-15 v1.1.10
|
||||
|
||||
### 관리자 사이트 설정을 전용 전체 화면으로 분리
|
||||
|
||||
공개 블로그 설정은 항목이 늘어날 예정이라 목록형 관리자 레이아웃 안에 두면 세로 공간과 시선 분산이 커진다. Ghost Admin처럼 설정만 별도 전체 화면으로 열고 좌측 앵커 내비와 우측 긴 스크롤을 두면 확장(타임존·공지·가져오기/보내기·스팸)을 같은 패턴으로 쌓을 수 있다. 따라서 `/admin/settings`에서는 기본 관리자 사이드바를 숨기고, 닫기·ESC와 문서 스크롤 잠금으로 집중도를 맞춘다.
|
||||
|
||||
## 2026-05-15 v1.1.9
|
||||
|
||||
### 추천 글을 저장 필드로 분리
|
||||
|
||||
홈 Featured와 목록의 번개 표시는 최신 글 여부가 아니라 운영자가 명시한 추천 상태여야 한다. 기존처럼 첫 번째 글을 추천처럼 보이게 하면 추천 의도와 최신순 정렬이 섞이므로, 게시물에 `is_featured` 필드를 추가하고 글쓰기 사이드바 토글로 관리하도록 했다. 추천 글이 없으면 홈 Featured 영역도 숨겨 빈 운영 상태에서 불필요한 섹션이 보이지 않게 한다.
|
||||
|
||||
### 관리자 글 목록 필터를 클라이언트 우선으로 도입
|
||||
|
||||
현재 관리자 글 목록은 전체 글을 한 번에 조회하는 구조라 상태·태그·정렬 필터를 클라이언트에서 먼저 적용해 변경 범위를 줄였다. 목록 규모가 커지면 같은 필터 기준을 `/admin/api/posts` 쿼리 파라미터로 옮길 수 있도록 상태 키와 태그 필터 계산을 별도 함수로 분리했다.
|
||||
|
||||
## 2026-05-15 v1.1.8
|
||||
|
||||
### 태그 순서 저장을 드롭 즉시 자동화
|
||||
|
||||
메인 태그 정렬은 드래그 자체가 명확한 저장 의도를 가진 조작이므로 별도의 `정렬 저장` 버튼을 두면 화면의 책임이 나뉘어 보인다. 태그 추가 버튼도 화면 전체 제목 옆에 있으면 메인 태그 추가처럼 보일 수 있어, 새 태그가 기본적으로 일반 태그로 생성되는 현재 구조에 맞춰 일반 태그 섹션 헤더 오른쪽으로 옮겼다. 순서 저장 중에는 추가 드래그를 잠시 막아 서버 순서와 화면 순서가 어긋나지 않게 한다.
|
||||
|
||||
## 2026-05-15 v1.1.7
|
||||
|
||||
### 사이트 로고 파일명을 교체마다 고유하게 저장
|
||||
|
||||
사이트 로고 업로드는 미디어 라이브러리에 `시스템` 폴더 메타로 남지만, 기존 구현은 항상 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 덮어썼다. 운영 브라우저와 파비콘 캐시는 같은 URL의 이미지를 오래 보관할 수 있어 파일이 바뀌어도 이전 이미지처럼 보일 수 있다. 따라서 로고와 파비콘은 업로드마다 고유 파일명으로 저장하고 사이트 설정 URL 자체를 갱신한다. 현재 사이트 설정에서 참조 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시해 실수로 이름을 바꾸거나 삭제하지 못하게 했다.
|
||||
|
||||
## 2026-05-15 v1.1.6
|
||||
|
||||
### 일반 태그도 검색 없이 보이는 관리 화면
|
||||
|
||||
메인 태그는 공개 카테고리 노출용이고 일반 태그는 게시물 분류 보조용이므로, 새 태그를 무조건 메인 태그로 올리면 공개 노출 의도가 섞인다. 다만 일반 태그를 검색해야만 볼 수 있으면 방금 만든 태그가 저장되지 않은 것처럼 느껴진다. 따라서 새 태그는 일반 태그 기본값을 유지하되, 관리자 태그 화면에서 일반 태그 전체 목록을 배지 형태로 항상 보여주고 필요할 때 메인 태그로 전환하도록 정리했다. 배지는 최근 사용순을 기본으로 하되 운영 판단에 따라 많이 사용순·이름순으로 바꿀 수 있게 했다.
|
||||
|
||||
## 2026-05-15 v1.1.5
|
||||
|
||||
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공
|
||||
|
||||
Nuxt 운영 빌드는 `public/`을 빌드 시점에 `.output/public`으로 복사해 정적 파일로 제공한다. 반면 Docker 운영 업로드는 컨테이너 실행 중 `/app/public/uploads` 볼륨에 기록되므로, 새 파일이 `.output/public` 스냅샷에 없으면 업로드 직후 이미지가 깨져 보일 수 있다. 업로드 파일은 사용자 콘텐츠이자 런타임 데이터이므로 빌드 산출물에 의존하지 않고 `/uploads/**` 요청을 `public/uploads`에서 직접 스트리밍하도록 결정했다.
|
||||
|
||||
## 2026-05-15 v1.1.4
|
||||
|
||||
### 관리자 멤버 썸네일 업로드 경로 분리
|
||||
|
||||
회원 프로필 썸네일은 관리자 계정인지 일반 회원인지와 무관하게 회원 자산이므로 `/uploads/members/avatars`에 저장해야 한다. 관리자 멤버 편집 화면이 공용 게시물 이미지 업로드 API를 사용하면 `/uploads/posts`에 저장되어 미디어 분류와 썸네일 생명주기 규칙이 어긋난다. 회원 설정 업로드와 관리자 멤버 업로드가 같은 검증·WebP 변환·1:1 크롭 로직을 쓰도록 공통 유틸로 분리하고, 관리자 멤버 화면은 회원 전용 업로드 API를 사용하도록 정리했다.
|
||||
|
||||
## 2026-05-13 v1.1.3
|
||||
|
||||
### 사이드바 행 호버 배경 분리
|
||||
|
||||
전역 `site-panel-hover`는 패널과 텍스트 색을 `color-mix`해 라이트에서도 호버가 진하게 느껴진다. 카드·태그 목록 등 다른 패널은 기존 대비를 유지하고, 왼쪽 사이드바 네비·카테고리·테마 점만 `site-sidebar-nav-row`로 분리해 라이트에서 `#F7F4EF`로 완화했다. 다크에서는 가독성을 위해 기존과 동일한 `color-mix` 호버를 유지한다.
|
||||
|
||||
## 2026-05-14 v1.1.2
|
||||
|
||||
### 태그 없을 때 “POST” 더미 표시 제거
|
||||
|
||||
태그 배열이 비어 있을 때 UI 폴백으로 `POST` 문자열을 넣어 두어, 사용자는 실제 태그가 붙은 것으로 오해했다. 저장 데이터와 무관한 표시이므로 슬러그가 있을 때만 첫 태그를 노출하고 없으면 태그 영역을 렌더하지 않는다.
|
||||
|
||||
## 2026-05-14 v1.1.1
|
||||
|
||||
### 공개 문단 행간 기본값으로 복귀
|
||||
|
||||
문단 글자 크기만 16px(`text-base`)로 고정하고 행간은 `leading-7` 대신 Tailwind·브라우저 기본(`leading-normal` 계열)에 맡긴다.
|
||||
|
||||
## 2026-05-14 v1.1.0
|
||||
|
||||
### 관리자 제목·공개 본문 타이포 마이너 조정
|
||||
|
||||
196
docs/map.md
196
docs/map.md
@@ -8,23 +8,37 @@
|
||||
|------|------|
|
||||
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
|
||||
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
||||
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
||||
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바(대시보드 `/admin` 활성 링크·사이트 보기 새 창·콘텐츠 구간 여백), 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금, **`/admin/settings`에서는 사이드바 숨김·본문 전체 화면·설정용 문서 스크롤 잠금** |
|
||||
| layouts/page.vue | 고정 페이지 전체 화면 |
|
||||
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template |
|
||||
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크, 기본 title template, 브랜드 컬러 `--site-accent` 적용 |
|
||||
| error.vue | 공개 404/서버 오류 화면, 홈 이동 버튼 |
|
||||
|
||||
## Composables
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| composables/formatPostDate.js | 공개 화면 게시일 `YYYY.MM.DD` 포맷 |
|
||||
| composables/formatPostDate.js | 공개 게시일 `YYYY.MM.DD`, 관리자·수정일 보조 `formatPostDateTime`, `wasPostUpdatedAfterPublish` |
|
||||
| composables/createPostSummary.js | 게시물 요약·본문에서 목록·SEO용 짧은 설명 텍스트 생성 |
|
||||
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
|
||||
|
||||
## 공유 라이브러리(서버·클라이언트 공통)
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree` |
|
||||
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·용량 문구(서버·클라이언트 공용) |
|
||||
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
|
||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·임베드·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별 |
|
||||
| lib/markdown-callout.js | 인용 막대 색상·콜아웃 배경색 옵션, 선언 줄 파싱·직렬화 |
|
||||
| lib/brand-color.js | 사이트 브랜드 컬러 기본값·hex 검증·정규화 |
|
||||
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
|
||||
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
|
||||
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 |
|
||||
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
|
||||
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
|
||||
| lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 |
|
||||
| lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·레거시 JSON 문자열 복구·공개 노출 목록 생성 |
|
||||
|
||||
## Nuxt 모듈
|
||||
|
||||
@@ -43,33 +57,50 @@
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
|
||||
| server/middleware/html-page-renderer.js | HTML 문서 모드 고정 페이지(`/pages/:slug`)를 Nuxt 렌더링 대신 `text/html` 원문으로 응답 |
|
||||
| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) |
|
||||
| server/routes/rss.xml.get.js | 공개 RSS 2.0 피드(`/rss.xml`) |
|
||||
| server/routes/feed.xml.get.js | 공개 RSS 2.0 피드 별칭(`/feed.xml`) |
|
||||
| server/routes/rss.get.js | 공개 RSS 2.0 피드 별칭(`/rss`) |
|
||||
| server/plugins/site-custom-code.js | 공개 Nuxt HTML 응답에 사이트 설정 헤더·푸터 코드 삽입(`/admin`, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 제외) |
|
||||
| server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 |
|
||||
|
||||
## 사이트 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
|
||||
| components/site/SiteTopChrome.vue | 공개 레이아웃 상단 고정 영역(어나운스 바+헤더), `--site-top-chrome-height` CSS 변수 |
|
||||
| components/site/SiteAnnouncementBar.vue | 공개 사이트 상단 어나운스 배너(문구·선택 링크·hex 배경색·텍스트 정렬·닫기) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운(아바타 없거나 비로그인 시 사람 아이콘), `/`·`SiteSearchModal` |
|
||||
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 게시물 상세 데스크톱 왼쪽 사이드 광고 슬롯, Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended·공통 사이드 광고 대신 H1~H3 TOC(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), 설정 기반 SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·구독 폼, About 영역은 비공개, `lg+` 스티키 |
|
||||
| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 왼쪽 사이드·게시물 본문 상단·인아티클·하단 광고 슬롯 |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(대표 이미지 없으면 제목 텍스트 플레이스홀더) |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
|
||||
## 관리자 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 제목 입력 text-3xl, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 백슬래시 hard break 줄바꿈 입력, 작성 모드 왼쪽 바깥 absolute 논리 줄 번호 거터·거터 스크롤 동기화·스크롤바 숨김, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 작성 모드 툴바 마크다운 삽입, 미리보기 모드 툴바 숨김, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG: 브랜드·사이트 정보·POST·사이트 코드·Ads·SNS·게시물보내기·가져오기 등·미구현 placeholder) |
|
||||
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
|
||||
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 왼쪽 사이드·게시물 본문 상단·인아티클·하단) |
|
||||
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
|
||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||
|
||||
## 관리자 컴포저블
|
||||
@@ -83,58 +114,67 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base·leading-7, 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
|
||||
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
|
||||
| lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
|
||||
| components/content/ProseList.vue | 목록 |
|
||||
| components/content/ProseBlockquote.vue | 인용구 |
|
||||
| components/content/ProseBlockquote.vue | 인용구, 왼쪽 세로 막대형 기본 스타일과 색상 프리셋, 다크모드 기본 인용 텍스트 가독성 보정 |
|
||||
| components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 줄번호 gutter, 공개 복사 버튼) |
|
||||
| components/content/ContentMarkdownCodeBlockEditor.vue | 라이브 모드 코드 블록 인라인 편집(Language·줄번호 토글) |
|
||||
| components/content/ProseButton.vue | 버튼 |
|
||||
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 배경 프리셋, 상단 여백 중심) |
|
||||
| components/content/ProseToggle.vue | Toggle 카드 |
|
||||
| components/content/ProseVideo.vue | 비디오 |
|
||||
| components/content/ProseAudio.vue | 오디오 |
|
||||
| components/content/ProseFile.vue | 파일 |
|
||||
| components/content/ContentMarkdownCalloutEditor.vue | 라이브 모드 콜아웃 멀티라인 본문 편집, 제목·아이콘 표시 여부·배경 반영, 내부 여러 줄 범위 선택 |
|
||||
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 선택 제목, 인용과 같은 배경 프리셋, 헤더 아래 본문 줄바꿈) |
|
||||
| components/content/ProseToggle.vue | Toggle 카드(펼침 애니메이션, chevron 트리거) |
|
||||
| components/content/ContentMarkdownToggleEditor.vue | 라이브 모드 토글 제목·본문 인라인 편집 |
|
||||
| components/content/ProseVideo.vue | `:::video` 공개 비디오 카드 |
|
||||
| components/content/ProseAudio.vue | `:::audio` 공개 오디오 플레이어 카드 |
|
||||
| components/content/ProseFile.vue | `:::file` 공개 다운로드 파일 카드, 제목·파일명이 같으면 용량 중심 보조 정보 표시 |
|
||||
| components/content/ProseProduct.vue | 상품 카드 |
|
||||
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) |
|
||||
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
|
||||
| components/content/ProseHeaderCard.vue | 헤더 카드 |
|
||||
| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 `http(s)` 외부 링크 |
|
||||
| components/content/ProseEmbed.vue | YouTube 영상 iframe, X/Twitter 소셜 iframe, Mastodon `/embed` iframe, 기타 `http(s)` 외부 링크 |
|
||||
|
||||
## 관리자 페이지
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드 |
|
||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
|
||||
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 현재 필터 결과 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 추천 사이트 대체 텍스트·썸네일 URL, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
|
||||
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
|
||||
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·파비콘 생성, 저작권 문구) |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명 읽기 행, **사이트 정보**(로고 미등록 점선 박스·URL·저작권 읽기 행), **SNS 정보**(아이콘 프리셋+주소 목록형 관리), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·hex 배경색·텍스트 정렬·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 작업이 있을 때만 표시되는 **최근 내보내기 작업** 별도 카드(준비 완료 배지 숨김·진행 중 진행도·만료일·파일 체크 선택·전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제), **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·완료 요약·누락 자산 경고 표시, 진행 중 요청 버튼 잠금 |
|
||||
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 읽기 전용 진입 후 수정하기 전환, 변경 시 저장 버튼 활성, 저장 버튼 기반 멤버 등급 변경, 권한 변경 불가 상황 셀렉트 잠금/텍스트 표시, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
|
||||
## 공개 페이지
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 라이트·다크 이미지 지원 `HomeHero`, Featured/Latest, Latest 피드 Compact 기본값·List·Cards 보기, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 로그인 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 해당 URL 재진입 |
|
||||
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
|
||||
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
||||
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
|
||||
@@ -144,13 +184,14 @@
|
||||
|
||||
| 파일 | 기능 |
|
||||
|------|------|
|
||||
| server/api/posts.get.js | 게시물 목록 샘플 API |
|
||||
| server/api/posts/[slug].get.js | 게시물 상세 샘플 API |
|
||||
| server/api/pages.get.js | 고정 페이지 목록 샘플 API |
|
||||
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
|
||||
| server/api/posts.get.js | 공개 게시물 목록 API(`published`, VIP 이상 등급 시 `members`) |
|
||||
| server/api/posts/[slug].get.js | 공개 게시물 상세 API(`published`, VIP 이상 등급 시 `members`) |
|
||||
| server/api/pages.get.js | 공개 고정 페이지 목록 API(`published`만) |
|
||||
| server/api/pages/[slug].get.js | 공개 고정 페이지 상세 API(`published`만) |
|
||||
| server/api/tags.get.js | 태그 목록 샘플 API |
|
||||
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
|
||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||
| server/api/homepage-widget.get.js | gethomepage customapi용 사이트 요약 위젯 API |
|
||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
|
||||
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
|
||||
@@ -160,7 +201,7 @@
|
||||
| server/utils/email-otp.js | OTP 생성·해시 |
|
||||
| server/utils/resend-mail.js | Resend REST 발송 |
|
||||
| server/api/auth/login.post.js | 회원 로그인 API |
|
||||
| server/api/auth/me.get.js | 회원 세션 조회 API |
|
||||
| server/api/auth/me.get.js | 회원 세션 조회 API(`isAdmin`, `role` 포함) |
|
||||
| server/api/auth/logout.post.js | 회원 로그아웃 API |
|
||||
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
|
||||
| server/api/auth/profile.put.js | 회원 프로필 수정 API(닉네임·`avatarUrl`; 관리 썸네일 URL 교체 시 메타만 분리) |
|
||||
@@ -180,6 +221,12 @@
|
||||
| server/routes/admin/api/posts/[id].get.js | 관리자 게시물 상세 API |
|
||||
| server/routes/admin/api/posts/[id].put.js | 관리자 게시물 수정 API |
|
||||
| server/routes/admin/api/posts/[id].delete.js | 관리자 게시물 삭제 API |
|
||||
| server/routes/admin/api/posts/export-jobs.get.js | 관리자 게시물 Export 작업 목록 API |
|
||||
| server/routes/admin/api/posts/export-jobs.post.js | 관리자 게시물 Export 작업 요청 API |
|
||||
| server/routes/admin/api/posts/export-jobs/[jobId].delete.js | 관리자 게시물 Export 작업·생성 ZIP 삭제 API |
|
||||
| server/routes/admin/api/posts/export-jobs/[jobId]/retry.post.js | 관리자 게시물 Export 실패 작업 재시도 API |
|
||||
| server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js | 관리자 게시물 Export 분할 ZIP 다운로드 API |
|
||||
| server/routes/admin/api/posts/import.post.js | 관리자 게시물 Export ZIP Import API |
|
||||
| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API |
|
||||
| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API |
|
||||
| server/routes/admin/api/pages/[id].get.js | 관리자 고정 페이지 상세 API |
|
||||
@@ -190,7 +237,8 @@
|
||||
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
||||
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 미디어 업로드 API(이미지·비디오·오디오·문서, 원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
|
||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||
@@ -199,18 +247,24 @@
|
||||
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
|
||||
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||
| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-YYYYMM-*.webp`, `/uploads/system/favicon-YYYYMM-*.png` 생성, `시스템` 미디어 메타 저장, DB 반영 없이 URL 반환) |
|
||||
| server/routes/admin/api/settings/home-cover.post.js | 관리자 메인 화면 커버 업로드 API(`/uploads/system/home-cover-YYYYMM-*.webp` 생성, `시스템` 미디어 메타 저장, 라이트·다크 슬롯용 URL 반환) |
|
||||
| server/routes/ads.txt.get.js | 사이트 설정 ads.txt 루트 text/plain 응답 |
|
||||
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
||||
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
|
||||
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
|
||||
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API |
|
||||
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
||||
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) |
|
||||
| server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API |
|
||||
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API(`owner`/`admin`/`vip`/`member`, 관리자 상호 조작 차단, 마지막 소유자 보호) |
|
||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/request-ip.js | 프록시 헤더 포함 요청 IP 조회 유틸리티 |
|
||||
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
|
||||
| server/utils/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·WebP 변환·중앙 1:1 크롭·저장 유틸 |
|
||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||
@@ -222,8 +276,23 @@
|
||||
| server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단 |
|
||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||
| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·ZIP 생성 워커 저장소 |
|
||||
| server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter·자산 재매핑·게시물 생성) |
|
||||
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||
| server/repositories/analytics-repository.js | 방문·게시물·유입 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 |
|
||||
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 |
|
||||
| server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 |
|
||||
| server/utils/zip-reader.js | 게시물 Import ZIP 읽기 유틸리티 |
|
||||
| server/api/analytics/pageview.post.js | 공개 통계 수집 API |
|
||||
| server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API |
|
||||
| server/utils/analytics-heartbeat-input.js | heartbeat 검증·기록 |
|
||||
| server/routes/admin/api/analytics/summary.get.js | 관리자 통계 요약 API |
|
||||
| server/routes/admin/api/analytics/posts.get.js | 관리자 인기 게시물 API |
|
||||
| server/routes/admin/api/analytics/pages.get.js | 관리자 인기 페이지 API |
|
||||
| server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API |
|
||||
| server/routes/admin/api/analytics/traffic.get.js | 관리자 유입원·디바이스·키워드 통계 API |
|
||||
| plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 |
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
@@ -233,6 +302,7 @@
|
||||
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
|
||||
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
||||
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
|
||||
| db/migrations/024_navigation_recommended_location.sql | `navigation_items.location`에 `recommended` 값 허용 |
|
||||
| db/migrations/017_navigation_hierarchy.sql | `navigation_items`에 `parent_id`·`is_folder`, `(location,label,url)` 유니크 제거 |
|
||||
| db/migrations/019_dedupe_navigation_items.sql | 반복 마이그레이션으로 생긴 네비게이션 중복 행 정리 및 중복 방지 인덱스 |
|
||||
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
|
||||
@@ -244,9 +314,25 @@
|
||||
| db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 |
|
||||
| db/migrations/011_add_member_profile_and_activity.sql | 회원 아바타/최근 활동 컬럼 추가 및 닉네임 유니크 인덱스 추가 |
|
||||
| db/migrations/012_add_comment_likes.sql | 댓글 좋아요 테이블 추가 |
|
||||
| db/migrations/014_add_user_role_levels.sql | 회원 권한 3단계(owner/admin/member) 컬럼 추가 |
|
||||
| db/migrations/014_add_user_role_levels.sql | 회원 권한 단계(owner/admin/member) 컬럼 추가 |
|
||||
| db/migrations/013_add_user_admin_role.sql | 회원 관리자 권한 컬럼 추가 및 첫 사용자 관리자 승격 |
|
||||
| db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 |
|
||||
| db/migrations/030_analytics_daily_stats.sql | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 |
|
||||
| db/migrations/031_analytics_engagement_and_realtime.sql | 체류·스크롤 집계 컬럼·실시간 접속 세션 테이블 |
|
||||
| db/migrations/032_add_post_author.sql | 게시물 작성자(`posts.author_id`) 컬럼 추가 및 기존 글 owner/admin backfill |
|
||||
| db/migrations/033_site_settings_home_cover_dark_image.sql | 사이트 설정 다크모드 홈 커버 이미지 URL 컬럼 추가 |
|
||||
| db/migrations/034_add_page_render_mode.sql | 고정 페이지 렌더링 모드 컬럼 추가 |
|
||||
| db/migrations/035_default_pages_to_html_document.sql | 고정 페이지 렌더링 모드 기본값을 HTML 문서로 변경 |
|
||||
| db/migrations/036_content_visibility_statuses.sql | 게시물 상태에 `members`/`private`, 페이지 상태에 `published`/`draft`/`private` 제약 추가 |
|
||||
| db/migrations/037_add_vip_member_role.sql | 회원 권한 단계에 `vip` 허용 |
|
||||
| db/migrations/038_restore_owner_when_missing.sql | 소유자가 없는 경우 기존 관리자 중 가장 오래된 계정을 소유자로 복구 |
|
||||
| db/migrations/039_page_analytics_and_navigation_recommended_metadata.sql | 페이지 통계 테이블·추천 사이트 대체 텍스트/썸네일 컬럼 추가 |
|
||||
| db/migrations/040_post_export_jobs.sql | 게시물 Export 작업·분할 파일 계획 테이블 추가 |
|
||||
| db/migrations/041_post_export_progress.sql | 게시물 Export 작업 진행도 컬럼 추가 |
|
||||
| db/migrations/042_post_export_date_range.sql | 게시물 Export 날짜 범위 컬럼 추가 |
|
||||
| db/migrations/043_post_export_size_and_error_detail.sql | 게시물 Export 목표 용량·실패 상세 로그 컬럼 추가 |
|
||||
| db/migrations/044_site_settings_custom_code.sql | 사이트 설정 ads.txt·공통 헤더 코드·공통 푸터 코드 컬럼 추가 |
|
||||
| db/migrations/045_analytics_traffic_sources.sql | 방문자 유입원·디바이스·키워드 일별 집계 테이블 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
@@ -255,12 +341,14 @@
|
||||
| package.json | Nuxt 실행 스크립트와 의존성 |
|
||||
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 |
|
||||
| tailwind.config.js | Tailwind 테마 설정 |
|
||||
| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
|
||||
| assets/css/main.css | 전역 스타일, 공개 배경·사이드바 배경 통일 기준, 관리자 라이트 테마 격리(`admin-layout`), 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
|
||||
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) |
|
||||
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
|
||||
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
|
||||
| scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 |
|
||||
| scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 |
|
||||
| scripts/migrate-database.js | 로컬·NAS DB 마이그레이션 적용/상태/baseline 실행 |
|
||||
| scripts/migrate-development-db.js | 기존 로컬 개발 DB 마이그레이션 명령 호환 래퍼 |
|
||||
| scripts/migrate-production-db.sh | npm 없는 NAS 호스트용 운영 DB 마이그레이션 적용/상태/baseline 실행 |
|
||||
| .env.example | 환경 변수 예시 |
|
||||
| Dockerfile | NAS 운영 이미지 빌드 |
|
||||
| docker-compose.yml | NAS 컨테이너 실행 초안 |
|
||||
|
||||
396
docs/spec.md
396
docs/spec.md
@@ -26,7 +26,7 @@
|
||||
| Left Aside(모바일) | `fixed` 좌측 패널, 열림 시 `translate-x-0`, 닫힘 시 화면 밖으로 이동. 열린 동안 백드롭과 `html.site-mobile-nav-open`으로 문서 스크롤 잠금 |
|
||||
| Main | 중앙 열 안에서 `max-width: 720px`·`justify-self: start`, 별도 `overflow-y` 없음. 공개 페이지의 가로 패딩은 레이아웃 그리드(`public-layout__grid`)의 `px-*` 한 번만 사용하고, 본문 섹션의 `px-*`는 두지 않는다. |
|
||||
| Right Aside | Left와 동일 패턴(스티키·고정 높이·내부 무스크롤바 스크롤·하단 카피라이트) |
|
||||
| Right Aside(모바일) | 본문 아래에 전체 너비로 배치, 상단 보더로 본문과 구분, 좌우 안전 여백(`px-4`) 적용 |
|
||||
| Right Aside(모바일) | 본문 아래에 전체 너비로 배치, 상단 보더로 본문과 구분 |
|
||||
|
||||
### 메뉴 토글
|
||||
|
||||
@@ -37,41 +37,62 @@
|
||||
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
|
||||
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
||||
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
|
||||
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Anonymous·Sign up·Sign in 메뉴를 표시한다.
|
||||
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Guest·Sign up·Sign in 메뉴를 표시한다. 회원 아바타 이미지가 없거나 비로그인 상태인 경우 사용자 메뉴 버튼에는 사람 아이콘을 표시한다.
|
||||
- 오른쪽 사이드바의 FOLLOW 영역은 사이트 설정의 SNS 링크 목록을 기준으로 표시한다. 관리자가 아이콘 프리셋 또는 직접 SVG 아이콘과 주소를 등록한 항목만 노출하며, 등록된 항목이 없으면 FOLLOW 영역 자체를 숨긴다. SNS 주소는 `https://`를 생략해도 저장 시 자동 보정한다. 관리자 편집 화면은 아이콘과 주소를 기본 입력으로 사용하며, 직접 SVG 프리셋을 선택했을 때만 SVG 코드 입력을 추가로 표시한다. 공개 FOLLOW 아이콘은 프리셋·직접 SVG 모두 16px 아이콘을 20px 버튼 중앙에 배치한다.
|
||||
|
||||
### 공개 화면 색상
|
||||
|
||||
- 라이트/다크 모드는 CSS 변수로 관리
|
||||
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
|
||||
- 브랜드 포인트 컬러는 사이트 설정의 `brandColor` 값을 공개 앱 루트의 `--site-accent` CSS 변수로 반영한다. 기본값은 `#ff4f2e`이며, 왼쪽 사이드 활성 네비게이션, 게시글 TOC 활성 항목, 댓글 등록 버튼 등 사용자 화면의 주요 강조 요소에 사용한다.
|
||||
- 어나운스 바는 사이트 설정의 문구·링크·배경색·텍스트 정렬을 반영한다. 배경색은 3/6자리 hex 값을 저장하며 기본값은 `#15171a`, 텍스트 정렬은 `center` 또는 `left`를 사용한다.
|
||||
- 라이트 모드 기본 배경은 `#fcfcfc`로 통일하고 패널 구분은 보더로 처리
|
||||
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리한다. 첫 페인트 전 `lib/site-theme-init.js` 인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는 `#site-splash`에 캐시된 로고 이미지 URL(`SITE_BRAND_LOGO_URL`, localStorage) 또는 사이트 제목(`NUXT_PUBLIC_SITE_TITLE`)을 잠깐 표시하고, 앱 마운트 후 `site-app-ready`로 숨긴다. `site_settings.logo_text`(기본 `井`)는 **이미지 로고가 없을 때** 헤더·사이드바에 쓰는 짧은 기호이며 localStorage·스플래시와는 별개다.
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 라인으로 구분한다. 사이드바 자체 배경은 라이트/다크 모두 기본 화면 배경(`--site-bg`)과 통일하고, 내부 카드형 요소만 패널 배경을 사용한다.
|
||||
|
||||
### 홈 Featured (인덱스)
|
||||
|
||||
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
|
||||
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
|
||||
- Featured 글에 대표 이미지가 없으면 목록 썸네일과 동일하게 카드 안에 게시물 제목을 표시하는 placeholder를 사용한다.
|
||||
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
|
||||
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
|
||||
|
||||
### 홈 Latest 피드
|
||||
|
||||
- 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다.
|
||||
- `compact`는 썸네일을 포함한 짧은 행 형태, `list`는 텍스트 중심 목록 형태, `cards`는 카드 그리드 형태로 표시한다.
|
||||
- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
|
||||
- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 메인 인피드 광고 코드가 있으면 Latest 게시물 목록 사이 한 곳에 브라우저 렌더 시점 기준으로 무작위 삽입한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
|
||||
|
||||
### Post 페이지
|
||||
|
||||
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
||||
- 사이트 설정 Ads의 게시물 본문 상단·하단 광고 코드가 있으면 본문 렌더링 전후에 광고 슬롯을 표시한다. 게시물 인아티클 광고 코드가 있으면 공백 제외 본문 길이 2,000자 미만에서는 표시하지 않고, 2,000자 이상은 전체 블록 40% 근처 일반 문단 뒤에 한 번, 6,000자 이상은 35%·70% 근처 일반 문단 뒤에 최대 두 번 표시한다. 광고 사이에는 최소 8개 블록 간격을 둔다.
|
||||
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
|
||||
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||
- 댓글·답글 등록 버튼은 입력값을 trim한 뒤 내용이 있을 때만 활성화한다.
|
||||
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
|
||||
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
|
||||
- 댓글 정렬은 `Best`(좋아요 우선), `Latest`, `Oldest`를 제공한다.
|
||||
- 댓글 정렬은 `인기순`(좋아요 우선), `최신순`, `오래된순`을 제공한다.
|
||||
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
|
||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다.
|
||||
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, 소스·라이브 모드 모두 `Cmd/Ctrl+K`로 링크 마크다운을 삽입한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
|
||||
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 TOC 항목을 강조하고, 목차 항목이 많으면 TOC 내부 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다. 게시물 상세에서는 오른쪽 사이드바의 공통 광고를 숨기고, 게시물 왼쪽 사이드 광고 코드가 있을 때 데스크톱 왼쪽 사이드바 하단에 광고 슬롯을 표시한다.
|
||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
|
||||
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||
- 공유·SEO 설명은 SEO 설명이 있으면 우선 사용하고, 없으면 게시물 요약, 요약도 없으면 본문에서 마크다운 기호를 제거한 짧은 텍스트를 사용한다.
|
||||
- 홈 Latest·게시물 목록·태그 목록의 카드 설명도 동일하게 요약이 비어 있으면 본문에서 `createPostSummary`로 짧은 텍스트를 만든다. 목록용 설명은 문자열에 수동 말줄임을 붙이지 않고 `post-summary-clamp` 전용 클래스가 실제 표시 줄 끝에서 말줄임을 처리한다.
|
||||
|
||||
### 공개 목록·상세의 발행일 표시
|
||||
|
||||
- API의 ISO 8601 `publishedAt`를 공개 UI에서는 로컬 날짜 기준 `YYYY.MM.DD`로 표시한다.
|
||||
- 변환은 `composables/formatPostDate.js`의 `formatPostDate`를 사용한다.
|
||||
- 관리자 목록·수정일 보조 라벨은 `formatPostDateTime`(`YYYY.MM.DD 오전/오후 HH:MM`)을 사용한다. 발행 후 수정 여부는 `wasPostUpdatedAfterPublish`로 판별하며, `site_settings.show_post_updated_at`이 true일 때만 관리자 글 목록에 「수정: …」를 노출한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
|
||||
- `<time>`에는 표시용 문자열과 함께 가능한 경우 원본 시각을 `datetime` 속성으로 둔다.
|
||||
|
||||
### Page 페이지
|
||||
@@ -79,12 +100,15 @@
|
||||
- About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용
|
||||
- 기본 게시물 목록에는 노출하지 않음
|
||||
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
|
||||
- 페이지는 `renderMode`로 렌더링 방식을 구분한다. 기본값은 `html_document`이며 관리자에서 붙여넣은 전체 HTML 문서를 공개 `/pages/:slug` 요청에서 `text/html` 원문으로 응답한다. `markdown`은 관리자 UI에서 `일반 텍스트`로 표시하며 기존 Markdown 콘텐츠 렌더러를 사용한다.
|
||||
- HTML 문서 모드는 관리자만 저장하는 신뢰 콘텐츠를 전제로 하며, `<head>`, `<style>`, `<body>`를 포함한 단일 랜딩 페이지 용도로 사용한다.
|
||||
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
|
||||
|
||||
### 공개 URL 구조
|
||||
|
||||
- `/posts` - 게시물 전체 목록
|
||||
- `/post/:slug` - 개별 게시물 상세
|
||||
- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. 게시물에 대표 이미지 또는 OG 이미지가 있으면 절대 URL로 변환해 `media:thumbnail`과 `media:content medium="image"`를 함께 포함한다.
|
||||
- `/tags` - 태그 전체 목록
|
||||
- `/tag/:slug` - 태그별 게시물 목록
|
||||
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
|
||||
@@ -93,13 +117,21 @@
|
||||
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
|
||||
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
||||
|
||||
### 사이트 광고 슬롯
|
||||
|
||||
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostSidebarCode`, `adPostTopCode`, `adPostInArticleCode`, `adPostBottomCode` 일곱 위치의 HTML 코드를 저장한다.
|
||||
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
|
||||
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
|
||||
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.
|
||||
|
||||
### 공개 인증 화면(초기)
|
||||
|
||||
- 회원가입 화면은 AFFiNE 참고 다크 테마 3단계 플로우를 제공한다.
|
||||
- 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다.
|
||||
- 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다.
|
||||
- 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다.
|
||||
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
|
||||
- 관리자 로그인 화면도 같은 다크 톤 폼 레이아웃을 사용하되, 일반 로그인과 구분되도록 폼을 화면 오른쪽에 배치하고 내부 타이틀·설명·필드·버튼도 오른쪽 정렬한다.
|
||||
- 로그인·회원가입(2단계)·관리자 로그인 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
|
||||
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
|
||||
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
|
||||
- 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
|
||||
@@ -113,12 +145,20 @@ layouts/
|
||||
└── admin.vue # 관리자 화면
|
||||
```
|
||||
|
||||
- 존재하지 않는 공개 경로 또는 공개되지 않는 콘텐츠는 Nuxt 전역 오류 화면(`error.vue`)으로 처리한다. 404 화면은 상태 코드, 안내 문구, 홈 이동 버튼을 표시한다.
|
||||
|
||||
### 관리자 레이아웃
|
||||
|
||||
- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
|
||||
- 관리자 로그인(`/admin/login`)을 제외한 관리자 화면은 공개 사이트 라이트/다크 테마와 분리된 라이트 UI로 고정한다. `admin-layout` 스코프에서 공개 테마 CSS 변수를 재정의하고, 글쓰기 화면을 제외한 일반 관리자 화면에만 폼 컨트롤 `color-scheme`을 라이트 값으로 재정의해 사용자 페이지 다크모드가 관리자 입력·테이블·패널 색상에 영향을 주지 않게 한다.
|
||||
- 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
|
||||
- 관리자 우측 캔버스는 기본 `min-h-screen`과 `bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
|
||||
- 대시보드 메뉴는 관리자 기본 페이지(`/admin`)로 이동하는 활성 링크로 표시한다.
|
||||
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
|
||||
- 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
|
||||
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 메인 태그를 드롭다운에서 추가할 수 있다. 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천하고, 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다.
|
||||
- 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다.
|
||||
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 전체 선택은 현재 검색·필터 결과만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다.
|
||||
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||
@@ -168,31 +208,42 @@ components/content/
|
||||
- 리스트
|
||||
- Unordered: `- 항목`
|
||||
- Ordered: `1. 항목`
|
||||
- 렌더링: `ProseList.vue` (마커 컬러, 간격, 줄높이 통일)
|
||||
- 렌더링: `ProseList.vue` (마커 컬러는 글쓰기 화면과 같은 파란 계열, 간격, 줄높이 통일)
|
||||
- 인용구
|
||||
- 기본: `> 한 줄` 또는 `>` 연속 여러 줄(멀티라인)
|
||||
- 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록
|
||||
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`)
|
||||
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`, 기본 인용은 다크모드에서도 밝은 배경 위 어두운 텍스트 유지)
|
||||
- 기본 인용 배경은 회색이며, `> [!bg=gray|blue|green|yellow|red|purple]` 옵션 줄로 색상을 지정한다. 분홍 옵션은 사용하지 않는다.
|
||||
- 관리자 라이브 작성 모드에서도 인용 블록에 포커스가 들어오면 오른쪽 블록 설정 패널에서 배경색을 수정한다.
|
||||
- 이미지
|
||||
- 기본: ``
|
||||
- 와이드/풀: `{width=wide|full}`
|
||||
- 기본: `` — 이미지 아래 캡션 없음
|
||||
- 캡션(표시용): `` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시
|
||||
- **파일명을 캡션으로 사용** 토글: URL 파일명을 캡션으로 저장·표시(``). 레거시 ``도 동일하게 해석
|
||||
- 와이드/풀: `{width=wide|full}` 또는 캡션·width 조합
|
||||
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경)
|
||||
- 이미지 갤러리
|
||||
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
|
||||
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 라이트박스, Esc 닫기·←/→ 이전·다음)
|
||||
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
|
||||
- 비디오·오디오·파일 카드
|
||||
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)
|
||||
- 오디오: `:::audio` ~ `:::` (`url`, `title`, `description`)
|
||||
- 파일: `:::file` ~ `:::` (`url`, `title`, `description`, `name`, `size`) — 다운로드 링크 카드
|
||||
- 렌더링: `ProseVideo.vue`, `ProseAudio.vue`, `ProseFile.vue`
|
||||
- 관리자 슬래시: `/video`, `/audio`, `/file`로 빈 템플릿 삽입 후 URL·메타 수정
|
||||
- 관리자 미디어 화면은 미디어 라이브러리 탭에서 전체·이미지·영상·음악·파일 종류 필터와 미사용 필터를 제공한다. 미사용은 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 항목을 의미한다. 비디오 항목은 브라우저에서 초반 프레임을 캔버스로 추출해 목록 썸네일로 표시하고, 추출 실패 시 `video` placeholder를 유지한다.
|
||||
- 문단과 줄바꿈
|
||||
- 관리자 Markdown-first 에디터에서 일반 Enter는 브라우저 기본 단일 줄 이동으로 새 문단을 만든다.
|
||||
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
|
||||
- 공개 본문 렌더러는 줄끝 백슬래시 또는 공백 2개 hard break가 있는 행만 같은 문단으로 묶고 `<br>`로 표시한다.
|
||||
- 관리자 Markdown-first 에디터에서 Enter는 새 문단(마크다운 한 줄)만 사용한다. Shift+Enter·문단 내 hard break는 지원하지 않는다.
|
||||
- 공개 본문 렌더러는 마크다운 한 줄을 한 문단으로 렌더링한다(레거시 줄끝 `\\`/공백 2개 표식은 표시 시 제거).
|
||||
- 내용 없는 빈 줄과 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)는 spacer 블록으로 렌더링해 작성자가 비운 줄 수만큼 공백을 보존한다.
|
||||
- 문단 하단 기본 간격은 10px(`mb-2.5`) 기준이며, 문단 글자 크기·행간은 `ContentMarkdownRenderer` 문단에 `text-base`·`leading-7`을 적용한다.
|
||||
- 문단 하단 기본 간격은 10px(`mb-2.5`) 기준이며, 문단 글자 크기는 `ContentMarkdownRenderer` 문단에 `text-base`(16px·`1rem`)만 지정하고 행간은 Tailwind·브라우저 기본에 맡긴다.
|
||||
- 제목은 `ProseHeading`에서 단계별 크기·굵기를 적용하고, 첫 제목(`first:`)을 제외한 상단 추가 여백은 컴포넌트 스타일로만 조정한다.
|
||||
- 카드류
|
||||
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
||||
- Toggle: `:::toggle 제목` ~ `:::`
|
||||
- Bookmark: `:::bookmark` ~ `:::` (본문은 `url=`, `title=`, `description=`, `thumbnail=` 키값 또는 첫 줄 URL·이어지는 제목/설명 줄)
|
||||
- Signup: `:::signup` ~ `:::` (선택: `title=`, `description=`, `button=`, `placeholder=`)
|
||||
- Embed: `:::embed` ~ `:::` (YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드)
|
||||
- Embed: 단독 `http(s)` URL 한 줄(기존 `:::embed` ~ `:::`도 렌더링 호환). YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드
|
||||
- 렌더링: `ProseCallout.vue`, `ProseToggle.vue`, `ProseBookmark.vue`, `ProseSignup.vue`, `ProseEmbed.vue`
|
||||
|
||||
---
|
||||
@@ -210,6 +261,14 @@ components/content/
|
||||
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
||||
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
||||
|
||||
### 마이그레이션 적용 이력
|
||||
|
||||
- `schema_migrations` 테이블은 적용 완료된 SQL 파일명을 `file_name` 기준으로 기록한다.
|
||||
- `npm run db:migrate:dev`와 `npm run db:migrate:prod`는 `db/migrations/*.sql` 중 `schema_migrations`에 없는 파일만 순서대로 실행한다.
|
||||
- `sh scripts/migrate-production-db.sh status`는 npm이 없는 NAS 호스트에서도 운영 DB의 적용/대기 파일 목록을 출력한다.
|
||||
- 기존 운영 DB에 `posts` 테이블은 있지만 `schema_migrations`가 없으면 `sh scripts/migrate-production-db.sh migrate`는 데이터 보호를 위해 001부터 자동 실행하지 않고 중단한다.
|
||||
- 기존 운영 DB가 현재 코드 기준으로 이미 최신이면 `sh scripts/migrate-production-db.sh baseline`으로 현재 마이그레이션 파일들을 실행 없이 적용 완료로 기록한 뒤 이후 새 파일만 적용한다.
|
||||
|
||||
### Posts (블로그 글)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -217,19 +276,56 @@ components/content/
|
||||
| id | UUID | Primary Key |
|
||||
| title | String | 제목 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| content | Text | 마크다운 콘텐츠 |
|
||||
| content | Text | Markdown 콘텐츠 |
|
||||
| excerpt | String | 요약 |
|
||||
| featured_image | String nullable | 대표 이미지 |
|
||||
| is_featured | Boolean | 홈 Featured 및 목록 번개 표시용 추천 글 여부 |
|
||||
| seo_title | String | SEO 제목 |
|
||||
| seo_description | String | SEO 설명 |
|
||||
| canonical_url | String | canonical URL |
|
||||
| noindex | Boolean | 검색엔진 노출 제외 여부 |
|
||||
| og_image | String nullable | OG 이미지 |
|
||||
| status | Enum | published/draft/private |
|
||||
| status | Enum | `published` / `draft` / `members` / `private`(예약은 `published` + 미래 `published_at`) |
|
||||
| published_at | DateTime | 발행일 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
> API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
|
||||
> 공개 게시물 목록·상세는 `published` 상태만 기본 노출하며, `members` 상태는 VIP 이상 등급(`vip`/`admin`/`owner`) 회원에게만 노출한다. `private`와 `draft`는 공개 화면에서 노출하지 않는다.
|
||||
|
||||
### PostExportJobs / PostExportFiles
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| post_export_jobs.id | UUID | Export 작업 ID |
|
||||
| post_export_jobs.requested_by | UUID nullable | 요청 관리자 회원 ID |
|
||||
| post_export_jobs.requested_email | String | 요청 당시 관리자 이메일 |
|
||||
| post_export_jobs.status | Enum | `queued` / `processing` / `ready` / `failed` / `expired` |
|
||||
| post_export_jobs.scope | Enum | `all` / `author` |
|
||||
| post_export_jobs.post_count | Integer | 작업 대상 게시물 수 |
|
||||
| post_export_jobs.processed_count | Integer | 처리 완료 게시물 수 |
|
||||
| post_export_jobs.current_part_index | Integer nullable | 현재 처리 중인 분할 순번 |
|
||||
| post_export_jobs.chunk_size | Integer | ZIP당 최대 게시물 수 안전 상한 |
|
||||
| post_export_jobs.max_file_size_bytes | BigInt | 분할 ZIP 목표 최대 용량 바이트 |
|
||||
| post_export_jobs.retention_days | Integer | 보존 일수, 최대 100 |
|
||||
| post_export_jobs.date_from | DateTime nullable | Export 대상 게시물 기준일 시작 시각, null이면 시작 제한 없음 |
|
||||
| post_export_jobs.date_to | DateTime nullable | Export 대상 게시물 기준일 종료 시각(exclusive), null이면 종료 제한 없음 |
|
||||
| post_export_jobs.range_label | String | 관리자 화면과 파일명에 표시할 범위 라벨 |
|
||||
| post_export_jobs.expires_at | DateTime | 만료 예정 시각 |
|
||||
| post_export_jobs.message | Text | 작업 메시지 |
|
||||
| post_export_jobs.progress_message | Text | 진행 상세 메시지 |
|
||||
| post_export_jobs.error_detail | Text | 실패 시 상세 오류 로그 |
|
||||
| post_export_jobs.started_at | DateTime nullable | 작업 시작 시각 |
|
||||
| post_export_files.id | UUID | 분할 파일 ID |
|
||||
| post_export_files.job_id | UUID | FK → PostExportJobs |
|
||||
| post_export_files.part_index | Integer | 분할 순번 |
|
||||
| post_export_files.post_start | Integer | 해당 zip의 시작 게시물 순번 |
|
||||
| post_export_files.post_end | Integer | 해당 zip의 끝 게시물 순번 |
|
||||
| post_export_files.file_name | String | `사이트명_범위_시작번호-끝번호.zip` 파일명 |
|
||||
| post_export_files.file_path | String | 실제 생성 파일 경로, 생성 전 빈 값 |
|
||||
| post_export_files.file_size_bytes | BigInt | 파일 크기, 생성 전 0 |
|
||||
| post_export_files.status | Enum | `pending` / `processing` / `ready` / `failed` / `expired` |
|
||||
|
||||
### Users
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -240,7 +336,7 @@ components/content/
|
||||
| password_hash | String | bcrypt 해시 비밀번호 |
|
||||
| avatar_url | String | 프로필 썸네일 URL |
|
||||
| is_admin | Boolean | 관리자 권한 여부 |
|
||||
| user_role | Enum | 권한 단계(`owner`/`admin`/`member`) |
|
||||
| user_role | Enum | 권한 단계(`owner`/`admin`/`vip`/`member`) |
|
||||
| last_seen_at | DateTime nullable | 마지막 접속 시각 |
|
||||
| last_seen_ip | String | 마지막 접속 IP |
|
||||
| created_at | DateTime | 생성일 |
|
||||
@@ -274,8 +370,10 @@ components/content/
|
||||
| id | UUID | Primary Key |
|
||||
| title | String | 제목 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| content | Text | 마크다운 콘텐츠 |
|
||||
| featured_image | String nullable | 대표 이미지 |
|
||||
| content | Text | HTML 문서 원문 또는 일반 텍스트 콘텐츠 |
|
||||
| render_mode | String | 렌더링 방식(`html_document`, `markdown`) |
|
||||
| featured_image | String nullable | 레거시 컬럼, 관리자 페이지 작성 UI에서는 사용하지 않음 |
|
||||
| status | Enum | `published` / `draft` / `private` |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -304,6 +402,19 @@ components/content/
|
||||
| logo_text | String | 레거시 텍스트 로고 fallback |
|
||||
| logo_url | String | 공개 로고 이미지 URL |
|
||||
| favicon_url | String | 파비콘 이미지 URL |
|
||||
| show_post_updated_at | Boolean | 관리자 글 목록 수정일 보조 표시 여부 |
|
||||
| home_cover_image_url | String | 라이트모드 홈 커버 이미지 URL |
|
||||
| home_cover_dark_image_url | String | 다크모드 홈 커버 이미지 URL |
|
||||
| home_cover_title | String | 홈 커버 오버레이 제목 |
|
||||
| home_cover_text | Text | 홈 커버 오버레이 본문 |
|
||||
| announcement_enabled | Boolean | 공개 어나운스 바 사용 여부 |
|
||||
| announcement_text | Text | 어나운스 바 문구 |
|
||||
| announcement_url | String | 어나운스 바 링크 URL |
|
||||
| announcement_background_color | String | 어나운스 바 배경색 |
|
||||
| signup_blocked_usernames | JSON Text | 가입 금지 닉네임 목록 |
|
||||
| ads_txt | Text | 루트 `/ads.txt` 응답 본문 |
|
||||
| custom_head_code | Text | 공개 HTML head 끝에 삽입할 코드 |
|
||||
| custom_footer_code | Text | 공개 HTML body 끝에 삽입할 코드 |
|
||||
| copyright_text | String | 저작권 문구 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -314,7 +425,7 @@ components/content/
|
||||
| id | UUID | Primary Key |
|
||||
| label | String | 메뉴 표시 이름 |
|
||||
| url | String | 내부 경로 또는 외부 URL |
|
||||
| location | Enum | primary/footer |
|
||||
| location | Enum | primary / footer / recommended |
|
||||
| sort_order | Integer | 표시 순서 |
|
||||
| is_visible | Boolean | 공개 화면 표시 여부 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
@@ -345,6 +456,34 @@ components/content/
|
||||
| tag_id | UUID | FK → Tags |
|
||||
| created_at | DateTime | 생성일 |
|
||||
|
||||
### Analytics (자체 최소 통계)
|
||||
|
||||
> 마이그레이션 `030_analytics_daily_stats.sql`. 원문 IP·User-Agent·쿠키 ID는 저장하지 않는다. 서버는 `date + IP + User-Agent + secret`으로 일 단위 `visitor_hash`만 생성·저장한다.
|
||||
|
||||
| 테이블 | 필드 | 설명 |
|
||||
|--------|------|------|
|
||||
| site_analytics_daily | day, page_views, visitors, engaged_views, total_engaged_seconds | 사이트 일별 페이지뷰·방문자·체류 집계 |
|
||||
| post_analytics_daily | day, post_id, views, reads, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 게시물 일별 조회·읽음·스크롤 구간 |
|
||||
| page_analytics_daily | day, page_id, views, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 페이지 일별 조회·방문자·스크롤 구간 |
|
||||
| analytics_traffic_daily | day, source_group, source_name, device_type, os_name, keyword, page_views, visitors | 유입원·디바이스·키워드 일별 축약 집계 |
|
||||
| analytics_daily_visitors | day, scope(`site`/`post`/`page`/`traffic`), post_id?, page_id?, visitor_hash, source_group?, source_name?, device_type?, os_name?, keyword? | 일별 방문자 해시 등록(중복 방문 제거용) |
|
||||
| analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, page_id?, page_slug, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) |
|
||||
|
||||
- 추적 대상: 공개 경로만. `/admin`, `/signin`, `/signup`, `/forgot-password`, `/settings`는 제외.
|
||||
- 봇 User-Agent는 서버에서 무시.
|
||||
- 페이지뷰 수집은 탭 최초 referrer와 현재 URL을 서버로 보내며, 서버는 원문 referrer를 저장하지 않고 검색·SNS·직접·기타, 디바이스·OS, 검색 키워드로 축약해 `analytics_traffic_daily`에 집계한다.
|
||||
- 검색 유입은 네이버·다음·구글·줌·빙을 우선 분류하고, SNS 유입은 카카오톡·페이스북·인스타그램·트위터/X·유튜브를 우선 분류한다. 검색 키워드는 referrer query에 남아 있는 경우만 표시한다.
|
||||
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
|
||||
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다.
|
||||
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간)을 조회한다.
|
||||
- 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 7일은 일자별로 표시하고, 30일 이상은 선택 기간에 따라 7일·14일·30일 단위로 묶어 카드 폭을 넘지 않게 한다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 표(table)나 외부 차트 라이브러리는 사용하지 않는다.
|
||||
- 관리자 대시보드 **방문자 유입 정보**는 선택 기간 기준 검색·SNS·기타(직접 포함) 유입원, 디바이스/OS, 유입 키워드를 표시한다.
|
||||
- 관리자 대시보드 **인기 게시물**은 선택 기간 조회수와 함께 최근 30일 월간 조회수, 작성일을 표시한다.
|
||||
- 관리자 차트는 최대 365일 범위를 조회한다.
|
||||
- `site_analytics_daily`, `post_analytics_daily`, `analytics_traffic_daily`는 사이트 전체 방문자와 게시물별 조회수, 유입 통계 누적 원본이므로 자동 삭제하지 않는다.
|
||||
- `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다.
|
||||
- `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다.
|
||||
|
||||
---
|
||||
|
||||
## API 구조
|
||||
@@ -372,14 +511,18 @@ components/content/
|
||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||
- `GET /api/tags` - 태그 목록
|
||||
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||
- `GET /api/site-settings` - 공개 사이트 설정
|
||||
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절)
|
||||
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
|
||||
- `GET /api/homepage-widget` - gethomepage customapi용 사이트 요약. `title`, `updatedAt`, `todayVisitors`, `todayPageViews`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `items[]`를 반환한다.
|
||||
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록한다.
|
||||
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결.
|
||||
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절)
|
||||
- `GET /ads.txt` - 사이트 설정의 ads.txt 본문을 `text/plain`으로 반환한다. 값이 없으면 빈 본문을 반환한다.
|
||||
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status`의 `emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
|
||||
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
|
||||
- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. 실제 메일 발송이 실패하면 방금 생성한 챌린지는 즉시 삭제한다. 발송 성공 후 같은 이메일·용도의 이전 pending 챌린지는 무효화한다. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요.
|
||||
- `POST /api/auth/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.
|
||||
- `POST /api/auth/login` - 회원 로그인
|
||||
- `GET /api/auth/me` - 현재 회원 세션 조회
|
||||
- `GET /api/auth/me` - 현재 회원 세션 조회(`id`, `username`, `email`, `avatarUrl`, `isAdmin`, `role`)
|
||||
- `POST /api/auth/logout` - 회원 로그아웃
|
||||
- `GET /api/auth/profile` - 회원 설정 조회
|
||||
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, `avatarUrl`). 이전 값이 `/uploads/members/avatars/` URL이고 새 값과 달라지면 `removeManagedAvatarAsset`으로 **메타만** 끊고 디스크 파일은 유지한다(`DELETE /api/auth/avatar`와 동일한 자산 정리 규칙).
|
||||
@@ -395,16 +538,31 @@ components/content/
|
||||
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
|
||||
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
|
||||
|
||||
### 업로드 파일 제공
|
||||
|
||||
- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다.
|
||||
|
||||
### 관리자 API (`/admin/api/`)
|
||||
|
||||
- `POST /admin/api/auth/login` - 로그인
|
||||
- `POST /admin/api/auth/logout` - 로그아웃
|
||||
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
|
||||
- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다.
|
||||
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간)
|
||||
- `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)
|
||||
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함)
|
||||
- `GET /admin/api/analytics/traffic?days=30` - 기간 내 유입원·디바이스·유입 키워드 통계
|
||||
- `GET /admin/api/posts` - 글 목록
|
||||
- `POST /admin/api/posts` - 글 작성
|
||||
- `GET /admin/api/posts/:id` - 글 상세
|
||||
- `PUT /admin/api/posts/:id` - 글 수정
|
||||
- `DELETE /admin/api/posts/:id` - 글 삭제
|
||||
- `GET /admin/api/posts/export-jobs` - 게시물 Export 작업 목록
|
||||
- `POST /admin/api/posts/export-jobs` - 게시물 Export 작업 요청. `dateRangeMode=all|year|month|custom`와 연도·월·날짜 범위, `maxFileSizeBytes`, `chunkSize`를 받아 작업 레코드와 용량 기준 분할 파일 계획을 만들고 백그라운드 ZIP 생성을 시작한다.
|
||||
- `DELETE /admin/api/posts/export-jobs/:jobId` - 완료·실패한 게시물 Export 작업과 생성 ZIP 파일 삭제
|
||||
- `POST /admin/api/posts/export-jobs/:jobId/retry` - 실패한 게시물 Export 작업을 준비 완료 파일은 유지한 채 나머지 분할 파일부터 재시도
|
||||
- `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드
|
||||
- `POST /admin/api/posts/import` - Export ZIP 파일 Import. multipart `file` 필드의 ZIP을 읽어 Markdown frontmatter를 게시물로 생성하고, ZIP 내부 `images/`·`files/` 자산을 새 업로드 URL로 재매핑한다. 응답은 `importedCount`, `assetCount`, `warningCount`, `warnings`, `posts`를 반환한다.
|
||||
- `GET /admin/api/pages` - 고정 페이지 목록
|
||||
- `POST /admin/api/pages` - 고정 페이지 작성
|
||||
- `GET /admin/api/pages/:id` - 고정 페이지 상세
|
||||
@@ -416,34 +574,46 @@ components/content/
|
||||
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링)
|
||||
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장)
|
||||
- `POST /admin/api/member-avatar` - 관리자 새 회원 생성 전 썸네일 사전 업로드(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
- `PUT /admin/api/tags/:id` - 태그 수정
|
||||
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
||||
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바, 사이트 코드 필드 포함)
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames`, `adsTxt`, `customHeadCode`, `customFooterCode` 포함). `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
|
||||
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
|
||||
- `POST /admin/api/settings/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 사이트 정보 저장 시 `PUT`으로 처리한다.
|
||||
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바·사이트 코드 필드 포함)
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
||||
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
||||
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다.
|
||||
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`vip`/`member`)
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다.
|
||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
|
||||
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
- 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
|
||||
- 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
|
||||
- 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
|
||||
- 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 검색, 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 둔다. 검색은 제목·슬러그·요약·본문·태그 기준 부분 일치로 적용한다.
|
||||
- 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. 추천 열과 제목 열 사이에는 대표 이미지 썸네일을 작은 크기로 표시하며, 이미지가 없으면 회색 placeholder만 유지한다.
|
||||
- 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
|
||||
- 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다.
|
||||
- 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
- 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||
- 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
|
||||
- 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
|
||||
- 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
- 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
|
||||
- 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
|
||||
- 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||
- 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
|
||||
- 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||
- 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
### 관리자 글 편집
|
||||
|
||||
@@ -452,24 +622,39 @@ components/content/
|
||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
|
||||
- 관리자 게시물/페이지 저장 API는 레거시 블록 배열·객체 본문 값이 들어와도 마크다운 문자열로 정규화한 뒤 저장한다.
|
||||
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
|
||||
- 본문 작성 모드에서 일반 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 처리한다. 일반 Enter는 단일 줄 이동으로 보여야 하며, Shift+Enter는 수정 모드에서도 보이는 줄끝 백슬래시 hard break를 남긴다.
|
||||
- 본문 작성 모드에서 Enter·Shift+Enter 모두 브라우저 기본 줄바꿈(한 줄)으로 동작한다. 문단 구분은 빈 줄로 한다.
|
||||
- 클립보드에 `text/html`이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다.
|
||||
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용하며, 툴바와 카드형 패널 외곽을 숨겨 본문만 표시한다.
|
||||
- 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
|
||||
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I`는 현재 선택 텍스트에 각각 굵게, 기울임 마크다운을 적용한다.
|
||||
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
|
||||
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
|
||||
- 관리자 미리보기에서 `ContentMarkdownRenderer`에 `interactive`를 켠다. 갤러리 이미지는 드래그로 순서를 바꿀 수 있으며, 드래그 중 다른 셀 위에 올리면 해당 셀에 주황 테두리와 「여기로 이동」 표시로 드롭 위치를 보여 준 뒤 `gallery-reorder`로 마크다운을 갱신한다.
|
||||
- 라이브 모드 단일 이미지 블록은 드래그 가능하다. `` 이미지 줄과 단독 이미지 URL 줄 모두 같은 이미지 블록으로 다룬다. 다른 이미지 블록 위에 드롭하면 두 줄을 `:::gallery` fenced block 한 개로 병합하며(`merge-images-to-gallery`), 문서 순서를 유지해 위쪽 이미지가 먼저 들어간다. 자동 인접 병합은 하지 않는다.
|
||||
- 라이브 모드 갤러리 블록은 이미지 블록과 같은 선택형 카드로 취급한다. Tab/클릭으로 포커스할 수 있고, 포커스 상태에서 방향키 위/아래 이동을 지원한다. 갤러리 이미지 hover/focus 시 개별 편집/삭제 버튼을 제공하며, 편집 버튼은 해당 이미지 줄 기준으로 갤러리 블록 설정 패널을 연다.
|
||||
- 라이브 모드 단일 이미지 블록을 기존 갤러리 이미지 셀에 드롭하면 해당 셀 뒤에 이미지를 추가하고 원래 단일 이미지 줄은 제거한다(`insert-image-to-gallery`).
|
||||
- 라이브 모드 갤러리 이미지를 블록 사이 얇은 삽입선(또는 문서 맨 아래 삽입선)에 드롭하면 해당 위치에 단일 이미지 마크다운 줄을 삽입하고 갤러리에서 제거한다(`extract-gallery-image`). 갤러리에 이미지가 1장만 남으면 갤러리 블록을 단일 이미지 줄로 바꾸고, 0장이면 갤러리 블록을 제거한다.
|
||||
- `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다.
|
||||
- 인용(`>`) 블록은 첫 인용 줄에 `> [!bg=yellow]` 또는 `> {bg=yellow}` 옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이며, 옵션이 없으면 회색 기본 인용 스타일을 쓴다.
|
||||
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하며, 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 소스 모드와 라이브 모드에서 현재 줄을 삭제하며, 소스 모드에서 여러 줄이 선택되어 있으면 선택 범위가 걸친 줄을 함께 삭제한다. 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 ` ```언어`·`nolinenos`(줄 번호 숨김)를 지원한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
|
||||
- 라이브 모드 공통 편집기는 한글 IME 조합 확정 Enter를 문단·빈 줄의 분리, 제목·목록의 아래 줄 삽입, 토글 제목의 본문 이동, 인용·콜아웃·코드·토글 본문의 텍스트 줄바꿈에 동일하게 연결한다. IME가 확정 Enter의 `keydown`을 전달하지 않고 `keyup`만 남기는 경우에도 조합 종료 직후 Enter로 보고 같은 동작을 실행한다. Shift+방향키 범위 선택은 `ContentMarkdownRenderer` Selection Bridge가 인접 편집 블록까지 확장한다. 단일 줄 블록의 Shift+위/아래는 커서 위치와 상관없이 현재 열 기준으로 이전·다음 편집 블록까지 확장하고, 멀티라인 편집 영역은 첫 줄·마지막 줄 경계에서 `data-source-line`~`data-source-line-end` 줄 범위로 탐색한다. `Cmd/Ctrl+A` 1회는 현재 블록 전체, 짧은 시간 안 2회는 라이브 본문 전체를 선택한다. 교차 블록·전체 선택 상태에서 **Backspace**·**Delete**·**Cmd/Ctrl+X**는 선택 범위를 마크다운 줄로 변환해 `content-replace`로 본문을 갱신한다. 콜아웃·인용 내부 전체 선택 삭제는 블록을 제거하지 않고 빈 본문 줄을 남긴다. 블록 이동 단축키는 Shift 조합일 때 실행하지 않으며, 선택 제스처가 우선한다. 콜아웃 본문은 하나의 멀티라인 편집 영역으로 유지해 `Shift+방향키` 선택이 내부 여러 줄을 가로지를 수 있게 한다. 라이브 멀티라인 블록은 브라우저 기본 DOM 줄 생성 대신 텍스트 값 치환 방식으로 줄바꿈을 삽입하며, contenteditable 값을 읽을 때 끝 줄바꿈을 보존해 첫 줄 빈 줄과 후행 빈 줄이 소스·라이브 모드 전환 또는 저장 중 유실되지 않게 한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들 수 있지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다.
|
||||
- 인라인 마크다운은 Obsidian식 `$...$` 첨자 토큰을 지원한다. `$H_2O$`는 `H`+아래첨자 `2`+`O`, `$2^8$`은 `2`+위첨자 `8`, `$_B^AR$`는 아래첨자 `B`와 위첨자 `AR`로 렌더링한다. 첨자 본문에 공백·기호가 필요하면 `$_{...}$`, `$^{...}$` 형식도 허용한다.
|
||||
- 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다.
|
||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `{width=...}` 형식으로 삽입한다.
|
||||
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `` 형식으로 삽입한다.
|
||||
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
|
||||
- 이미지 너비 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 작성 모드에서 커서가 이미지 마크다운 줄 또는 `:::gallery` 블록 안에 있으면 현재 미디어 블록 편집 패널을 표시한다.
|
||||
- 현재 미디어 블록 편집 패널은 alt, URL, 너비 값을 수정하고 갤러리 이미지 순서 변경, 삭제, 미디어 라이브러리 이미지 추가를 지원한다.
|
||||
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄, 기존 `:::embed`, 인용문, `:::callout`, 코드 fenced 블록, `:::toggle` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다.
|
||||
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 캡션으로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL), 인용 배경색, 콜아웃 제목·아이콘·배경색, 코드 언어·줄번호, 토글 기본 펼침 상태. `AdminMarkdownEditor`는 `block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
|
||||
- 미디어 라이브러리 갤러리 다중 선택 시 선택 항목은 **주황(`#ff7a00`) 굵은 테두리**로 표시한다.
|
||||
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
||||
- 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
|
||||
- 글 작성/수정 화면의 저장 버튼은 현재 입력값이 마지막 저장 기준점과 다를 때만 활성화한다.
|
||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태 문구, Preview, 상태별 주요 액션(Publish / Update·Unpublish / Update·Unschedule), 설정 패널 토글을 제공한다.
|
||||
- 도구막대 상태 문구는 영어로 표시한다. **초안**: 편집 중 `Draft`, 저장(수동·서버 자동 저장) 진행 중 `Saving...`, 서버 기준과 동일할 때 `Draft - Saved`(신규 작성에서도 첫 `POST` 저장 후에는 `Draft - Saved`를 사용할 수 있다). **즉시 발행**: 공개 URL이 있으면 `Published ↗`를 링크로, 없으면 동일 문구만 표시한다. **예약 발행**: `Scheduled`를 `#2BBA3C`·보통 굵기로 표시하고 마우스 오버 시 영문 한 줄로 예약 시각을 `title` 툴팁에 보여 준다. **멤버십**은 `Members`, **비공개**는 `Private`로 표시한다.
|
||||
- **초안**(예약 발행 제외)은 입력 변경 후 약 1.2초 디바운스로 서버 자동 저장을 호출한다(슬러그가 유효할 때만). 제목이 비어 있으면 DB/API 저장 시에만 `(제목 없음)` 플레이스홀더를 쓰고, 관리자 폼·목록 API 응답의 `title`은 빈 문자열로 내려 준다. 임시 슬러그(`d`+24자리 hex)는 제목을 직접 수정하기 전까지 제목 입력에 따라 슬러그가 따라가며, 사용자가 슬러그를 직접 고친 뒤에는 자동 연동하지 않는다. 신규 작성 화면 마운트 시 슬러그가 비어 있으면 임시 슬러그를 채운다. 기존 글은 `PUT /admin/api/posts/:id`, **신규 작성**은 첫 저장 시 `POST /admin/api/posts`로 행을 만든 뒤 `replace`로 `/admin/posts/:id` 편집 화면으로 옮긴다. **이미 발행·예약으로 서버에 반영된 글**은 사이드바 등으로 폼만 초안처럼 바뀌어도 자동 저장하지 않으며, `Update`를 눌렀을 때만 `PUT`으로 반영한다(툴바의 `Publish`/`Update`/`Unpublish`/`Unschedule` 분기도 서버에 반영된 게시 형태를 기준으로 한다). 다른 화면으로 나가기 직전에는 디바운스 대기 중인 초안 변경이 있으면 타이머를 취소하고 한 번에 `POST` 또는 `PUT`으로 플러시한다.
|
||||
- `Publish`는 **서버에 아직 초안으로만 저장된 글**에만 표시되며 클릭 시 전체 화면 발행 모달을 연다. 초안에서 연 모달의 기본 선택은 **발행·지금 바로**이다. 모달 본문은 뷰포트 세로 중앙에 가깝게 배치하고, 상단에는 제목·닫기만 둔다(도구막대의 `Preview` 버튼은 두지 않는다). 모달에서는 상태(발행/초안)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정하며, 예약 시각은 날짜·시간(KST 표기) 입력을 분리한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
|
||||
- `Update`는 발행·예약·멤버십·비공개 글에 표시되며, 마지막 저장 이후 변경이 있을 때만 활성화된다(활성 텍스트 `#394047`, 비활성 `#8E9CAC`). `Publish`·활성화된 `Update`·`Unpublish`·`Unschedule`에는 호버 시 배경 `#f1f3f4`를 적용한다.
|
||||
- `Unpublish`·`Unschedule` 클릭 시 Ghost형 전체 화면 확인 화면을 연다. 발행·예약 시각 요약과 **발행 취소하고 초안으로 되돌리기 →**(또는 예약 취소 문구) 링크를 눌렀을 때만 `status`를 초안으로 되돌리고 `published_at`을 비운 뒤 `PUT`으로 저장한다.
|
||||
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
|
||||
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
|
||||
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
|
||||
@@ -482,10 +667,6 @@ components/content/
|
||||
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
|
||||
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
||||
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
||||
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
||||
- 자동 저장본이 있으면 상단 툴바의 상태 문구 옆에서 복원 또는 무시(로컬 초안 삭제)를 선택할 수 있다.
|
||||
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
||||
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
||||
- 미리보기 본문·헤로 영역은 공개 상세와 동일하게 중앙 `max-w-[720px]` 컬럼과 `px-4 sm:px-5` 수평 패딩을 적용한다.
|
||||
@@ -506,11 +687,12 @@ components/content/
|
||||
- 관리자 폼에서는 검색엔진 노출 제외(`noindex`)만 설정할 수 있다.
|
||||
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
||||
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
|
||||
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `` 또는 파일명 캡션 토글 시 `` 형식으로 저장한다. 단독 이미지 파일 URL(`jpg`, `png`, `webp`, `gif`, `avif`, `svg`) 한 줄은 임베드가 아니라 이미지 블록으로 렌더링한다.
|
||||
- 이미지/갤러리 삽입 시 캡션은 기본 비우며, 블록 설정 패널에서 **파일명을 캡션으로 사용** 토글로 이미지 아래에 URL 파일명을 표시한다.
|
||||
- 라이브 모드 이미지 블록은 hover/focus 시 우측 상단에 `편집`·`삭제` 버튼을 표시한다. `편집`은 기존 오른쪽 이미지 설정 패널을 열어 이미지 URL·캡션·파일명 캡션 사용 여부를 수정한다.
|
||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||
- 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
|
||||
@@ -518,30 +700,57 @@ components/content/
|
||||
- 관리자 갤러리 블록의 이미지 순서는 드래그 앤 드롭으로 변경하며, 드래그 중 삽입 위치를 이미지 사이 라인으로 표시한다.
|
||||
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
||||
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
||||
- 관리자 미디어 업로드 API는 이미지(`jpg`, `png`, `webp`, `gif`), 비디오(`mp4`, `webm`, `mov`), 오디오(`mp3`, `wav`, `ogg`, `m4a`), 파일(`pdf`, `zip`, `txt`, `csv`, `docx`, `xlsx`, `pptx`)을 지원한다.
|
||||
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
|
||||
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`를 저장할 수 있다. 예: `:::callout emoji=💡 bg=blue`
|
||||
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`, `title`을 저장할 수 있다. 예: `:::callout emoji=none bg=blue title="주의사항"`
|
||||
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
|
||||
- 콜아웃 배경 프리셋은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`를 지원한다.
|
||||
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
|
||||
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
|
||||
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
|
||||
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다.
|
||||
- 콜아웃 배경 프리셋은 인용 블록과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`를 지원한다.
|
||||
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다.
|
||||
- 임베드 블록은 이미지 파일 URL을 제외한 단독 `http(s)` URL 한 줄을 기본 저장 형식으로 사용한다.
|
||||
- 기존 `:::embed` fenced block은 이전 콘텐츠 호환을 위해 계속 파싱·렌더링한다.
|
||||
- 관리자 Markdown-first 에디터의 라이브/스타일 모드에서 임베드 블록은 URL 입력 카드 없이 즉시 실제 임베드 프리뷰로 표시된다. 임베드·비디오·오디오·파일 프리뷰 카드는 hover/focus 시 우측 상단 삭제 버튼을 표시한다. 블록 래퍼에 포커스한 상태에서 `Backspace`·`Delete`·`Ctrl/Cmd+Shift+K`로 삭제하고, `Enter`로 아래 빈 줄을 추가하며, `ArrowUp`·`ArrowDown`은 브라우저 스크롤 대신 이전/다음 편집 줄로 이동한다.
|
||||
- 라이브/스타일 모드에서 제목 블록 Enter는 현재 제목 내용을 저장한 뒤 바로 아래 빈 문단을 추가하고, 원문 마크다운 편집 상태로 전환하지 않는다.
|
||||
- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다.
|
||||
- 소스 모드 라인 번호는 논리 줄 수를 표시하되, 긴 문장 자동 줄바꿈으로 textarea의 한 줄 높이가 늘어나면 라인 번호 칸도 같은 높이로 맞춘다.
|
||||
- 소스 모드에서 라이브 모드로 전환하면 현재 textarea 커서 줄과 줄 안 오프셋을 기준으로 대응하는 라이브 편집 블록에 포커스를 두고, 대상 줄이 화면 중앙에 가깝게 보이도록 스크롤한다.
|
||||
- 라이브 모드에서 소스 모드로 전환하면 현재 포커스된 블록 또는 화면 상단에 가까운 원본 줄을 기준으로 textarea 커서와 스크롤 위치를 복원한다.
|
||||
- YouTube 임베드 URL은 공개 화면에서 본문 폭 기준 16:9 iframe으로 렌더링한다.
|
||||
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다. X 공식 iframe의 내부 최대 폭 때문에 공개 화면에서는 카드 폭을 좁혀 중앙 정렬한다.
|
||||
- Mastodon 공개 게시물 URL(`/@user/id`, `/users/user/statuses/id`)은 `{원본 URL}/embed` iframe으로 렌더링한다. iframe 로드 후 Mastodon 공식 embed 방식과 같은 `postMessage` 높이 요청을 보내 응답 높이를 반영한다. 인스턴스가 embed를 차단하거나 지원하지 않으면 브라우저 iframe 정책에 따라 표시되지 않을 수 있다.
|
||||
- 그 외 URL은 외부 링크 텍스트 카드로 표시한다.
|
||||
- 비디오 블록은 `:::video` fenced block 안에 `url`, `title`, `poster`, `caption` 값을 저장하며 공개 화면에서 가로형 비디오 카드로 렌더링한다. 관리자 `/video` 슬래시 명령은 비디오 미디어 선택·업로드 모달을 열고 선택 파일 URL을 자동으로 채운다.
|
||||
- 오디오 블록은 `:::audio` fenced block 안에 `url`, `title`, `description` 값을 저장하며 공개 화면에서 아이콘+플레이어 카드로 렌더링한다. 관리자 `/audio` 슬래시 명령은 오디오 미디어 선택·업로드 모달을 열고 선택 파일 URL을 자동으로 채운다.
|
||||
- 파일 블록은 `:::file` fenced block 안에 `url`, `title`, `description`, `name`, `size` 값을 저장하며 공개 화면에서 다운로드 카드로 렌더링한다. 관리자 `/file` 슬래시 명령은 문서 파일 선택·업로드 모달을 열고 URL·파일명·크기를 자동으로 채운다.
|
||||
- 북마크 블록은 `:::bookmark` fenced block으로 저장할 수 있으며 공개 화면에서 Thred형 가로 카드로 렌더링한다.
|
||||
- 회원가입(뉴스레터) CTA는 `:::signup` fenced block으로 저장할 수 있으며 실제 폼 연동은 후속 작업으로 분리한다.
|
||||
|
||||
### 관리자 페이지 편집
|
||||
|
||||
- 고정 페이지 작성/수정 화면은 게시물과 같은 블록형 에디터를 사용한다.
|
||||
- 고정 페이지는 제목, 슬러그, 본문, 대표 이미지만 저장한다.
|
||||
- 고정 페이지 작성/수정 화면은 게시글 작성 화면과 같은 전체 화면 에디터 구조를 사용한다. 상단 툴바에 목록 이동, 저장 상태, 저장 버튼, 설정 패널 토글을 두고 오른쪽 설정 패널은 접고 펼칠 수 있다.
|
||||
- 고정 페이지 작성/수정 화면의 기본 모드는 HTML 문서 모드이며, `markdown` 모드는 `일반 텍스트`로 표시한다.
|
||||
- 고정 페이지 HTML 문서 모드는 전체 HTML 붙여넣기용 textarea를 사용하고, 공개 URL에서 Nuxt 레이아웃 없이 원문 HTML로 응답한다. HTML 문서 textarea에서 본문이 비어 있거나 `!`만 입력된 상태로 Tab을 누르면 기본 HTML 골격을 실제 본문에 자동 완성한다.
|
||||
- 페이지 슬러그는 게시글처럼 한글 제목을 영문으로 로마자화해 자동 생성한다.
|
||||
- 페이지 상태, 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
|
||||
- 페이지 형식 선택은 HTML 문서/일반 텍스트 모드와 무관하게 항상 표시한다. HTML 자산 업로드는 HTML 문서 모드에서만 표시한다.
|
||||
- HTML 자산 업로드는 기존 관리자 업로드 API(`/admin/api/uploads`)를 사용하며, 성공한 파일 URL을 HTML textarea 현재 커서 위치에 삽입한다. 업로드 파일은 현재 에디터 업로드 정책에 따라 `/uploads/posts/YYYY/MM/` 아래 저장되고 미디어 라이브러리 논리 폴더는 `미분류`로 기록된다.
|
||||
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 대표 이미지는 페이지 작성 UI에서 사용하지 않는다.
|
||||
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
|
||||
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.
|
||||
- 고정 페이지 상태는 `published`, `draft`, `private`를 사용한다. 공개 목록·상세·HTML 문서 미들웨어는 `published` 상태만 응답하고, `draft`와 `private`는 공개 URL에서 찾을 수 없는 페이지로 처리한다.
|
||||
|
||||
### 사이트 설정
|
||||
|
||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 게시물 백업 도구는 `게시물 내보내기`와 `게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼만 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 내보내기 작업 목록은 작업이 있을 때만 내보내기 설정 카드 아래 별도 카드로 표시하며, 완료 작업은 상태 배지를 숨기고 만료일과 준비 완료 파일 선택 목록을 중심으로 표시한다. 전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·완료/실패 작업 삭제를 제공하며, 진행도 영역은 대기·생성 중 작업에서만 표시한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import API를 호출한다. 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **사이트 코드** 카드는 ads.txt, 공통 헤더 코드, 공통 푸터 코드의 등록 여부를 라벨/값 세로 행으로 보여 주고, `편집`을 눌렀을 때만 textarea와 저장/취소가 나타난다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 라벨/값 세로 행으로 읽기 전용 표시하고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리하며, 읽기 화면의 로고는 등록 상태 문구 없이 이미지 또는 점선 미등록 박스로 표시한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
|
||||
- 게시물 Import/Export 1차 포맷은 Obsidian 호환 백업 번들을 기준으로 한다. Export는 게시물마다 별도 폴더를 만들고, 폴더 안에 `제목.md` 메인 파일과 `images/`, `files/` 같은 자산 폴더를 함께 둔다. 본문은 기존 Markdown을 최대한 유지하되 `/uploads/...`로 연결된 내부 이미지·파일은 번들 안의 로컬 파일로 복사하고, Markdown 참조는 `./images/...` 또는 `./files/...` 같은 상대 경로로 재작성한다. 제목·슬러그·상태·발행일·요약·대표 이미지·SEO·태그는 YAML frontmatter에 저장한다. Import는 같은 구조의 ZIP을 읽어 frontmatter를 게시물 메타데이터로 복원하고, 로컬 자산은 `/uploads/posts/YYYY/MM/`에 새 파일로 저장한 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. 태그는 inline 배열(`tags: ["a"]`)과 Obsidian식 블록 배열(`tags:\n- a`)을 모두 읽는다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 `slug-2`, `slug-3`처럼 새 슬러그로 가져온다. ZIP 안에서 자산을 찾지 못한 경우 Import는 계속 진행하되 응답 경고로 누락 경로를 알려 준다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다.
|
||||
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 준비 완료 파일은 체크박스로 선택한 뒤 전체 선택 또는 선택 파일 다운로드로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있으며, 실패 상세 로그는 작업 카드에서 확인한다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다.
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 함께 생성한다.
|
||||
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 라이트모드와 다크모드 프리뷰를 상하로 모두 표시하고, 각 모드 제목 오른쪽의 이미지 변경·삭제 버튼으로 개별 이미지를 관리한다. 이미지가 비어 있는 모드는 점선 드롭존으로 표시하며 파일 선택과 드래그 앤 드롭 업로드를 지원한다. 커버 파일 업로드·제목·본문은 편집 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
|
||||
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
|
||||
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
|
||||
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
|
||||
- **사이트 코드**(`ads_txt`, `custom_head_code`, `custom_footer_code`): `ads_txt`는 루트 `/ads.txt`에서 `text/plain`으로 응답한다. `custom_head_code`는 공개 Nuxt HTML 응답의 `head` 끝에, `custom_footer_code`는 `body` 끝에 원문 HTML로 삽입한다. 관리자 페이지, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 응답에는 삽입하지 않는다.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp`와 `/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 업로드 API는 파일 URL만 반환하고, 실제 `logo_url`·`favicon_url` DB 반영은 사이트 정보 카드의 **저장** 버튼에서 처리한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
@@ -549,14 +758,14 @@ components/content/
|
||||
### 메뉴/네비게이션
|
||||
|
||||
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
|
||||
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`|`recommended`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
|
||||
- 동일 `location`·`parent_id`·`label`·`url` 조합은 중복 저장하지 않는다. 반복 마이그레이션으로 생긴 중복은 `019_dedupe_navigation_items.sql`이 정리하고, 표현식 유니크 인덱스로 재발을 막는다.
|
||||
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**(레거시·중복 행으로 인한 사이드바 이중 표시 방지). `footer`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
|
||||
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
||||
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `isVisible`을 내려준다.
|
||||
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 `primary`·`footer`·`recommended` 각각 위치별 트리(또는 평면 루트) DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 **상단은 한 단계(루트→자식)만** 허용한다. `footer`·`recommended` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 홈 **오른쪽 사이드바** Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고 `https://` URL이면 클라이언트에서 호스트를 뽑아 Google Favicon 프록시(`https://www.google.com/s2/favicons?domain=…`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략).
|
||||
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-panel-hover` 배경이 가로 전체를 쓴다.
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
|
||||
### 관리자 인증
|
||||
@@ -565,29 +774,36 @@ components/content/
|
||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||
- DB에 owner/admin 계정이 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
|
||||
- 최초 owner 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
|
||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키(`sori_admin_session`)를 `/` 경로에 설정한다. Secure는 실제 HTTPS 요청(`x-forwarded-proto` 포함)일 때만 사용한다.
|
||||
- `/admin/login` 로그인 제출 버튼은 제출 중일 때만 비활성화한다. 빈 값은 브라우저 `required`와 서버 검증으로 처리하며, 자동완성 값은 제출 직전 동기화한다.
|
||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. 요청 IP는 프록시 헤더(`x-forwarded-for`)를 포함해 기록한다.
|
||||
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
|
||||
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다.
|
||||
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
|
||||
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
||||
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다. 상태 열은 멤버 등급을 먼저 표시하고, 비활성 회원만 작은 보조 상태로 표시한다.
|
||||
- 관리자 멤버 목록은 글 목록과 같은 테두리형 검색 입력, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
||||
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
|
||||
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
||||
- 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다.
|
||||
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 기존 회원 상세는 처음에는 읽기 전용 보기 모드로 현재 값만 표시하고, 상단 `수정하기`를 누르면 입력 필드·셀렉트가 편집 컨트롤로 전환된다. 저장 버튼은 수정 모드에서 기존 값과 달라진 내용이 있을 때만 활성화한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 소유자 본인이나 관리자가 다른 관리자·소유자를 보는 등 서버에서 변경 불가한 경우 등급 셀렉트는 비활성화하고, 등급 변경 권한이 없는 세션에서는 등급을 일반 텍스트로 표시한다. 저장·업로드·비밀번호 변경 피드백은 우측 상단 관리자 토스트로 표시한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
||||
- 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다.
|
||||
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
|
||||
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.
|
||||
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. 관리자는 다른 관리자·소유자의 권한을 변경할 수 없고, 소유자·관리자 등급을 부여할 수 없다. 소유자는 모든 등급을 관리할 수 있으나 본인 권한을 직접 낮출 수 없고, 시스템에는 최소 1명의 소유자가 항상 남아야 한다.
|
||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
||||
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
||||
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
||||
|
||||
### 게시물 작성자
|
||||
|
||||
- `posts.author_id`는 게시물을 만든 회원 ID이며 `users.id`를 참조한다(`ON DELETE SET NULL`).
|
||||
- 관리자 게시물 생성 시 현재 관리자 세션의 `userId`를 `author_id`로 저장한다.
|
||||
- 기존 게시물은 마이그레이션 `032_add_post_author.sql`에서 owner/admin 계정이 정확히 1개일 때만 해당 계정으로 `author_id`를 채운다. 여러 관리자 계정이 있으면 임의 배정을 피하기 위해 자동 backfill하지 않는다.
|
||||
- 공개 게시글 상세의 편집 아이콘 노출은 관리자 여부가 아니라 현재 로그인 회원 ID와 `posts.author_id` 일치 여부를 기준으로 한다.
|
||||
|
||||
### 회원 인증
|
||||
|
||||
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
||||
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `GET/PUT /api/auth/profile`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
|
||||
- 회원 로그인 성공 시 `previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다.
|
||||
- 회원 로그인 성공 시 `previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. 요청 IP는 프록시 헤더(`x-forwarded-for`)를 포함해 기록한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다.
|
||||
- 사용자 설정 화면은 공개 본문 폭에 맞춰 프로필 요약을 상단에 두고, 프로필 입력과 활동 정보를 하단에 배치한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다.
|
||||
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
||||
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`만 사용하며, 값이 없으면 서버 오류로 실패한다.
|
||||
@@ -603,32 +819,34 @@ components/content/
|
||||
/uploads/posts/YYYY/MM/filename.webp
|
||||
/uploads/pages/YYYY/MM/filename.webp
|
||||
/uploads/members/avatars/YYYY/MM/filename.webp
|
||||
/uploads/system/logo.png
|
||||
/uploads/system/favicon.png
|
||||
/uploads/system/logo-YYYYMM-random.webp
|
||||
/uploads/system/favicon-YYYYMM-random.png
|
||||
/uploads/system/home-cover-YYYYMM-random.webp
|
||||
```
|
||||
|
||||
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
|
||||
- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다.
|
||||
- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시 `-2` 넘버링)이다. 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다.
|
||||
- 사이트 로고·파비콘·홈 커버는 `public/uploads/system/`에 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`시스템`**으로 저장한다. 현재 사이트 설정의 `logo_url`, `favicon_url`, `home_cover_image_url`, `home_cover_dark_image_url`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다.
|
||||
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
|
||||
|
||||
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
||||
- 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
|
||||
- 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB).
|
||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **썸네일** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 썸네일 탭에서는 `/members/avatars/` 파일만 검색·탐색한다.
|
||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **프로필 이미지** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다.
|
||||
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
|
||||
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다.
|
||||
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
||||
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
|
||||
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
|
||||
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(프로필 이미지 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
|
||||
- API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라 `useAdminToast` 우측 상단 토스트로 표시해 모달에 가리지 않는다.
|
||||
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘·홈 커버 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
|
||||
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 프로필 이미지 탭에서 정리할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
@@ -649,10 +867,14 @@ DB_PORT=43119
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
ANALYTICS_HASH_SECRET=replace-with-random-password
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
MAX_VIDEO_FILE_SIZE=209715200
|
||||
MAX_AUDIO_FILE_SIZE=52428800
|
||||
MAX_DOCUMENT_FILE_SIZE=52428800
|
||||
AVATAR_MIN_WIDTH=96
|
||||
AVATAR_MIN_HEIGHT=96
|
||||
AVATAR_MAX_WIDTH=512
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## 1차 관리자 개발
|
||||
|
||||
- [ ] Markdown-first 에디터 3차 개선: 옵시디언식 Live Preview(마크다운 토큰 숨김/백스페이스 복원), 표준 마크다운 파서 도입 검토, HTML 붙여넣기 변환 범위 확대
|
||||
- [ ] 게시물 Import 후속 검증: Export ZIP 샘플을 실제 Obsidian vault에서 열어 경로·frontmatter 호환성 확인
|
||||
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
|
||||
|
||||
## 프론트엔드 개발
|
||||
@@ -26,9 +26,6 @@
|
||||
- [ ] ProseButton Left/Center 정렬 검증
|
||||
- [ ] ProseCallout 실제 스타일 세부 조정
|
||||
- [ ] ProseToggle 실제 스타일 세부 조정
|
||||
- [ ] ProseVideo 실제 임베드 렌더링 연결
|
||||
- [ ] ProseAudio 실제 오디오 렌더링 연결
|
||||
- [ ] ProseFile 실제 파일 데이터 연결
|
||||
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||
|
||||
@@ -41,3 +38,4 @@
|
||||
|
||||
- [ ] NAS 운영 환경 변수 최종 점검
|
||||
- [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA
|
||||
- [ ] 백업/복구 시스템 설계 및 구현(다음달 작업): PostgreSQL `pg_dump` 자동 백업, `public/uploads/` 압축 백업, 보관 주기, 복구 절차, 실제 복구 테스트 포함
|
||||
|
||||
887
docs/update.md
887
docs/update.md
@@ -1,9 +1,894 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.76
|
||||
|
||||
- 관리자 사이트 설정: 좌측 내비 아이콘을 브랜드·사이트 정보·POST 설정·사이트 코드·Ads·SNS 정보·게시물보내기·가져오기 항목에 추가.
|
||||
|
||||
## v1.5.75
|
||||
|
||||
- 게시물 글쓰기: 오른쪽 설정 패널 하단에 단어 수·공백 제외 문자 수·공백 수·예상 읽기 시간·블록 수·이미지 수 통계 추가.
|
||||
- 게시물 글쓰기: 본문 통계를 삭제 버튼 위의 작은 정보 영역으로 표시하도록 추가.
|
||||
|
||||
## v1.5.74
|
||||
|
||||
- 사이트 설정 Ads: 게시물 왼쪽 사이드 광고 슬롯 추가.
|
||||
- 공개 게시물 화면: 오른쪽 TOC 사이드바에서는 공통 사이드 광고를 숨기고, 데스크톱 왼쪽 사이드바 하단에 게시물 전용 광고를 표시하도록 수정.
|
||||
- 게시물 본문: 인아티클 광고를 글 길이에 따라 짧은 글 0회, 중간 글 1회, 긴 글 최대 2회까지 삽입하도록 조정.
|
||||
|
||||
## v1.5.73
|
||||
|
||||
- 사이트 설정 Ads: 게시물 인아티클 광고 슬롯 추가.
|
||||
- 게시물 본문: 충분히 긴 글에서 전체 블록 40% 근처 일반 문단 뒤에 인아티클 광고를 한 번 삽입하도록 추가.
|
||||
- 사이트 설정 저장소: 게시물 인아티클 광고 코드 컬럼과 관리자 입력 검증 추가.
|
||||
|
||||
## v1.5.72
|
||||
|
||||
- 사이트 설정 Ads: 메인 인피드 광고 슬롯 추가.
|
||||
- 메인 화면: Latest 게시물 목록 사이 한 곳에 인피드 광고를 무작위 삽입하도록 추가.
|
||||
- 사이트 설정 저장소: 메인 인피드 광고 코드 컬럼과 관리자 입력 검증 추가.
|
||||
|
||||
## v1.5.71
|
||||
|
||||
- 사이트 설정: 위치별 광고 코드를 관리하는 Ads 카드 추가.
|
||||
- 공개 화면: 메인 Featured·Latest 사이, 오른쪽 사이드 하단, 게시물 본문 상단·하단 광고 슬롯 추가.
|
||||
- 사이트 설정 저장소: 광고 슬롯 코드 컬럼과 관리자 입력 검증 추가.
|
||||
|
||||
## v1.5.70
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 마지막 줄 `!!!` Enter 콜아웃 단축 생성 시 본문 빈 줄을 두 줄로 만들도록 수정.
|
||||
- 게시물 글쓰기: 라이브 콜아웃·인용 내부 `Cmd/Ctrl+A` 후 Delete가 선택 범위를 실제 본문 삭제로 반영하도록 Selection Bridge Range 교차 판정 수정.
|
||||
- 게시물 글쓰기: 라이브 인용·콜아웃 줄바꿈 직후 한글 첫 글자 자모 분리 방지를 위해 plain text 멀티라인 Enter 삽입 경로 정리.
|
||||
|
||||
## v1.5.69
|
||||
|
||||
- 게시물 글쓰기: 라이브 인용·콜아웃 멀티라인 본문 Enter 줄바꿈이 끝 줄에서 잘리지 않도록 contenteditable 읽기 옵션 보강.
|
||||
- 게시물 글쓰기: 라이브 인용·콜아웃에서 `Cmd+Shift+K` 줄 삭제·Backspace 병합 직후 오래된 DOM이 다시 커밋되지 않도록 구조 변경 stale blur 차단 추가.
|
||||
|
||||
## v1.5.68
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 Shift+위/아래 선택 확장을 커서 위치 기준으로 보강. 단일 줄 블록에서는 커서가 중간에 있어도 인접 블록 같은 열까지 선택 확장.
|
||||
- 게시물 본문: Obsidian식 `$H_2O$`, `$2^8$`, `$_B^AR$` 위첨자·아래첨자 인라인 렌더링 추가.
|
||||
|
||||
## v1.5.67
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 Shift+방향키 블록 경계 선택 확장 보강. 줄 범위 기반 블록 탐색·선택 포커스 경계 판별·Shift+방향키 블록 이동 충돌 제거.
|
||||
- 게시물 글쓰기: 라이브 모드 교차 블록·전체 선택 후 Backspace/Delete/Cmd+X 삭제를 마크다운 본문에 반영하도록 Selection Bridge 삭제 경로 추가.
|
||||
|
||||
## v1.5.66
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 Selection Bridge 추가. Shift+방향키로 서로 다른 문단·블록 범위 선택 확장 지원.
|
||||
- 게시물 글쓰기: 라이브 모드 `Cmd/Ctrl+A` 1회는 현재 블록 전체, 2회는 본문 전체 선택으로 단계화.
|
||||
- 게시물 글쓰기: 라이브 미리보기 루트 포커스에서 `Cmd/Ctrl+A`로 본문 전체 선택 지원.
|
||||
|
||||
## v1.5.65
|
||||
|
||||
- 게시물 글쓰기: 라이브 인용·콜아웃·코드·토글 본문에서 한글 조합 확정 Enter의 `keydown`이 전달되지 않고 `keyup`만 남는 경우에도 줄바꿈이 즉시 실행되도록 수정.
|
||||
|
||||
## v1.5.64
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드에서 Shift+방향키 범위 선택과 `Cmd/Ctrl+A` 전체 선택이 블록 이동 단축키에 가로막히던 문제 수정.
|
||||
|
||||
## v1.5.63
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 공통 편집기의 한글 IME 조합 중 Enter 처리를 문단·빈 줄·제목·목록·토글 제목·인용·콜아웃·코드·토글 본문 전체에 동일하게 적용.
|
||||
- 게시물 글쓰기: 한글 글자 조합 확정 Enter 직후에도 해당 블록의 줄바꿈·블록 분리·다음 필드 이동 동작이 한 번에 이어지도록 수정.
|
||||
|
||||
## v1.5.62
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드에서 줄 병합(Backspace) 후 Enter로 다시 나눌 때 이전 블록 DOM이 남아 본문이 중복·통째 줄로 커밋되던 문제 수정.
|
||||
|
||||
## v1.5.61
|
||||
|
||||
- 게시물 글쓰기: 콜아웃 본문 첫 줄이 빈 줄일 때 소스·라이브 전환 후 `focusEditableAtLine`이 멀티라인 편집 영역 전체를 비워 본문이 사라지던 문제 수정.
|
||||
|
||||
## v1.5.60
|
||||
|
||||
- 게시물 글쓰기: 라이브 콜아웃·코드·인용 등 멀티라인 편집 영역 Enter를 브라우저 기본 DOM 줄 생성 대신 텍스트 줄바꿈 삽입으로 처리하도록 수정.
|
||||
- 게시물 글쓰기: 라이브 fenced 블록 편집 반영 시 현재 문서의 닫는 fence 줄을 다시 찾아 오래된 줄 범위로 본문이 유실될 수 있던 문제 수정.
|
||||
- 게시물 글쓰기: 콜아웃 첫 줄 앞에 빈 줄을 만든 뒤 소스·라이브 모드를 오갈 때 본문 보존을 보강.
|
||||
|
||||
## v1.5.59
|
||||
|
||||
- 게시물 글쓰기: 라이브 멀티라인 블록 편집 텍스트를 `<br>` HTML 대신 원문 텍스트와 `pre-wrap`으로 복원하도록 수정.
|
||||
- 게시물 글쓰기: 콜아웃 첫 줄이 빈 줄인 상태에서 소스·라이브 모드를 오가면 본문이 사라질 수 있던 문제 수정.
|
||||
|
||||
## v1.5.58
|
||||
|
||||
- 게시물 글쓰기: 라이브 인용 첫 글자 앞 Backspace 시 인용 블록을 일반 문단으로 되돌리도록 수정.
|
||||
- 게시물 글쓰기: 라이브 코드·인용·콜아웃·토글 블록 맨 위에서 위 방향키로 외부 기본 문단을 만들며 빠져나오도록 추가.
|
||||
- 게시물 글쓰기: 라이브 멀티라인 블록 편집 중 부모 모델 갱신이 포커스 중인 편집 DOM을 덮어써 내용이 사라질 수 있던 문제 수정.
|
||||
|
||||
## v1.5.57
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 인용을 줄 단위 편집 대신 단일 멀티라인 편집 영역으로 변경.
|
||||
- 게시물 글쓰기: 라이브 문단에서 `>` 입력 시 인용 블록으로 즉시 전환되도록 추가.
|
||||
- 게시물 글쓰기: 라이브 코드 블록 본문 입력 중 줄 번호와 원본 마크다운이 즉시 동기화되도록 수정.
|
||||
- 게시물 글쓰기: 라이브 콜아웃 본문 입력 중 원본 마크다운이 즉시 동기화되도록 수정.
|
||||
- 게시물 글쓰기: 콜아웃 등 멀티라인 블록 내부 두 번째 줄 이후 맨 앞 Backspace가 본문을 중복 병합하던 문제 수정.
|
||||
|
||||
## v1.5.56
|
||||
|
||||
- 게시물 글쓰기: 소스 모드 코드·콜아웃·토글 선언 줄에서 라이브 모드로 전환해도 실제 본문 줄로 포커스가 복원되도록 수정.
|
||||
- 게시물 글쓰기: 라이브 모드 마지막 코드·콜아웃·토글 본문에서 아래 방향키로 외부 기본 문단을 만들며 빠져나오도록 수정.
|
||||
- 게시물 글쓰기: 닫히지 않은 코드 펜스가 아래 콘텐츠를 코드 블록으로 삼키지 않도록 수정.
|
||||
- 게시물 글쓰기: 라이브 모드 문단에서 ``` 또는 `!!!` 입력 후 Enter로 코드 블록·콜아웃을 빠르게 만들 수 있도록 추가.
|
||||
- 게시물 글쓰기: 소스·라이브 모드 `Cmd/Ctrl+K` 링크 삽입 단축키 추가.
|
||||
- 게시물 본문: 인용문을 배경 카드형 대신 왼쪽 세로 막대형 기본 스타일로 변경.
|
||||
- 게시물 본문: 콜아웃 색상별 보더 표시 추가.
|
||||
|
||||
## v1.5.55
|
||||
|
||||
- 게시물 글쓰기: 소스 모드 `Cmd+Shift+K` 현재 줄 삭제 단축키 복구.
|
||||
- 게시물 글쓰기: 라이브 모드 미리보기 루트 포커스 상태에서도 `Cmd+Shift+K`가 현재 원본 줄을 삭제하도록 보강.
|
||||
- 게시물 글쓰기: 선택 범위가 걸친 소스 모드 여러 줄 삭제 처리 추가.
|
||||
|
||||
## v1.5.54
|
||||
|
||||
- 게시물 글쓰기: 콜아웃 레이아웃을 아이콘·제목 헤더와 본문 줄바꿈 구조로 변경.
|
||||
- 게시물 글쓰기: 콜아웃 제목 옵션을 오른쪽 블록 설정 패널에 추가.
|
||||
- 게시물 글쓰기: 콜아웃 선언부 `title` 옵션 저장·파싱 추가.
|
||||
- 게시물 글쓰기: 새 콜아웃 기본 아이콘 표시를 사용하지 않도록 변경.
|
||||
|
||||
## v1.5.53
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 콜아웃 본문을 단일 멀티라인 편집 영역으로 정리해 `Shift+방향키` 범위 선택이 내부 여러 줄을 가로지르도록 수정.
|
||||
- 게시물 글쓰기: 콜아웃 아이콘 미사용 시 라이브 편집 화면의 `+` 자리 표시자를 숨기도록 수정.
|
||||
- 게시물 글쓰기: 라이브·사용자 화면 콜아웃 아이콘을 모두 왼쪽 상단 정렬로 통일.
|
||||
- 게시물 글쓰기: 라이브 모드 멀티라인 콜아웃에서 아래 방향키가 새 본문 줄을 만들지 않도록 보정.
|
||||
|
||||
## v1.5.52
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 fenced 블록의 원본 범위가 닫는 `:::` 다음 줄까지 포함되던 문제 수정.
|
||||
- 게시물 글쓰기: 연속 콜아웃에서 위 콜아웃 편집 시 아래 콜아웃 선언 줄이 삭제되던 문제 수정.
|
||||
- 게시물 글쓰기: 한글 조합 종료 직후 Enter 중복 이벤트를 전역 capture 단계에서 1회 차단하도록 보강.
|
||||
|
||||
## v1.5.51
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 인용·콜아웃에서 한글 조합 Enter가 줄 추가를 중복 실행하지 않도록 보정.
|
||||
- 게시물 글쓰기: 라이브 모드 콜아웃 마지막 줄 아래 방향키가 새 본문 줄을 만들지 않도록 수정.
|
||||
- 게시물 글쓰기: 라이브 모드 인용 마지막 줄 아래 방향키만 외부 문단을 만들도록 분리.
|
||||
|
||||
## v1.5.50
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 인용 Enter가 한글 조합 입력 뒤에도 내부 다음 인용 줄을 만들도록 수정.
|
||||
- 게시물 글쓰기: 라이브 모드 인용 마지막 줄에서 아래 방향키로 외부 문단을 만들며 빠져나오도록 보강.
|
||||
- 게시물 글쓰기: 라이브 모드 콜아웃 본문을 줄 단위 편집으로 전환하고 한글 조합 Enter 중복 줄 생성 문제를 완화.
|
||||
|
||||
## v1.5.49
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 `Cmd+Shift+K`가 코드·콜아웃·토글 블록 내부 현재 줄을 삭제하도록 수정.
|
||||
- 게시물 글쓰기: 코드·콜아웃·토글 블록 내부 마지막 본문 줄에서 `Cmd+Shift+K`를 누르면 fenced 블록 전체를 삭제하도록 수정.
|
||||
- 게시물 글쓰기: 콜아웃 라이브 편집에서 내부 줄 삭제와 마지막 줄 아래 방향키 이탈 이벤트가 부모 에디터로 전달되도록 수정.
|
||||
- 게시물 글쓰기: 라이브 슬래시 명령 Enter 적용 시 키 이벤트 전파를 더 강하게 차단해 한글 IME 입력 잔여 문자가 블록 본문에 남을 가능성을 줄임.
|
||||
|
||||
## v1.5.48
|
||||
|
||||
- 게시물 글쓰기: 상단 상태 표시에서 외부 이동 아이콘과 게시물 링크 이동 기능 제거.
|
||||
- 게시물 글쓰기: 상단 상태 영역은 현재 상태 텍스트만 표시하도록 정리.
|
||||
|
||||
## v1.5.47
|
||||
|
||||
- 공개 RSS 피드: 게시물 대표 이미지 또는 OG 이미지를 `media:thumbnail`·`media:content`로 포함하도록 추가.
|
||||
- 공개 RSS 피드: 상대 이미지 경로를 사이트 URL 기준 절대 URL로 변환하도록 추가.
|
||||
- 공개 RSS 피드: Media RSS 네임스페이스 선언 추가.
|
||||
|
||||
## v1.5.46
|
||||
|
||||
- 게시물 글쓰기: 콜아웃 배경색에서 분홍 옵션 제거.
|
||||
- 게시물 글쓰기: 콜아웃 배경색을 인용 블록과 같은 색상 팔레트로 통일.
|
||||
- 게시물 글쓰기: 라이브 모드 방향키 이동 시 콜아웃·인용 블록 선언 줄에 커서가 걸려 사라지던 문제 수정.
|
||||
- 게시물 글쓰기: 작은 화면에서 게시물 설정 패널이 본문 폭을 압축하지 않도록 고정 오버레이로 표시.
|
||||
|
||||
## v1.5.45
|
||||
|
||||
- 게시물 글쓰기: 인용 블록 기본 배경색을 회색으로 변경.
|
||||
- 게시물 글쓰기: 인용 블록 배경색 선택에서 분홍 옵션 제거.
|
||||
- 게시물 글쓰기: 인용 블록 오른쪽 설정 패널의 색상 배지를 실제 인용 블록 색상과 동일하게 수정.
|
||||
- 게시물 글쓰기: 라이브 모드에서 콜아웃·인용 블록에 포커스가 들어오면 오른쪽 블록 설정 패널이 열리도록 수정.
|
||||
- 게시물 글쓰기: 라이브 모드 콜아웃 내부 설정 모달을 제거하고 소스 모드와 같은 오른쪽 패널 방식으로 통일.
|
||||
|
||||
## v1.5.44
|
||||
|
||||
- 관리자 사이트 설정: 블로그 제목·설명 읽기 화면을 라벨/값 세로 행 레이아웃으로 정리.
|
||||
- 관리자 사이트 설정: 사이트 정보 읽기 화면에서 로고 등록 상태 텍스트를 제거하고, 미등록 시 점선 박스로 표시하도록 수정.
|
||||
- 관리자 사이트 설정: 사이트 코드 읽기 화면을 가로 3분할 대신 라벨/값 세로 행 레이아웃으로 정리.
|
||||
- 관리자 사이트 설정: 읽기 행의 라벨·값을 14px 일반 두께로 통일하고 행 내부 세로 중앙 정렬을 맞춤.
|
||||
|
||||
## v1.5.43
|
||||
|
||||
- 공개 RSS 피드: `/rss.xml`, `/feed.xml`, `/rss` 경로 추가.
|
||||
- 공개 RSS 피드: 최근 공개 발행글 최대 50개를 RSS 2.0 XML로 응답하도록 추가.
|
||||
- 사이트 설정 SNS 프리셋: RSS 기본 주소를 실제 피드 경로 `/rss.xml`로 변경.
|
||||
|
||||
## v1.5.42
|
||||
|
||||
- 공개 오른쪽 사이드바: 직접 SVG SNS 아이콘도 기존 프리셋 아이콘과 같은 16px 크기와 중앙 정렬로 표시되도록 수정.
|
||||
|
||||
## v1.5.41
|
||||
|
||||
- 관리자 사이트 설정: SNS 링크가 JSONB 배열이 아닌 JSON 문자열로 저장되어 저장 후 목록이 비어 보이던 문제 수정.
|
||||
- 관리자 사이트 설정: 기존에 잘못 저장된 SNS 링크 JSON 문자열을 배열로 복구하는 DB 마이그레이션 추가.
|
||||
- 관리자 사이트 설정: SNS 편집 UI에서 불필요한 이름 입력칸을 제거하고 아이콘·주소 중심 레이아웃으로 정리.
|
||||
|
||||
## v1.5.40
|
||||
|
||||
- 관리자 사이트 설정: SNS 링크 주소 입력 시 `https://`를 생략해도 자동 보정되도록 수정.
|
||||
- 관리자 사이트 설정: SNS 아이콘 프리셋에 `직접 SVG` 옵션을 추가해 새 SNS 아이콘을 코드 수정 없이 등록할 수 있도록 수정.
|
||||
- 관리자 사이트 설정: SNS 링크 편집에서 표시 이름과 사용자 SVG 아이콘 입력을 지원하도록 추가.
|
||||
- 공개 오른쪽 사이드바: 사용자 지정 SVG SNS 아이콘을 FOLLOW 영역에 표시하도록 수정.
|
||||
|
||||
## v1.5.39
|
||||
|
||||
- 관리자 사이트 설정: 일반 섹션에 SNS 정보 카드 추가.
|
||||
- 관리자 사이트 설정: SNS 링크를 아이콘 프리셋과 주소 조합의 목록형으로 입력·저장할 수 있도록 추가.
|
||||
- 공개 오른쪽 사이드바: 사이트 설정에 등록된 SNS 링크만 FOLLOW 영역에 표시하도록 수정.
|
||||
- DB: `site_settings.social_links` JSONB 컬럼 추가.
|
||||
|
||||
## v1.5.38
|
||||
|
||||
- 관리자 사이트 설정: 어나운스 바 배경색을 프리셋뿐 아니라 직접 hex 색상으로 선택·입력할 수 있도록 수정.
|
||||
- 관리자 사이트 설정: 어나운스 바 텍스트 정렬을 중앙 또는 왼쪽으로 선택할 수 있도록 추가.
|
||||
- 공개 어나운스 바: 설정된 배경색과 텍스트 정렬을 반영하도록 수정.
|
||||
- 게시물 글쓰기: 한글 IME 조합 입력 중 코드·콜아웃·토글 블록 설정 패널이 줄바꿈 뒤 닫히지 않도록 보강.
|
||||
- DB: `site_settings.announcement_alignment` 컬럼 추가.
|
||||
|
||||
## v1.5.37
|
||||
|
||||
- 게시물 글쓰기: 콜아웃 블록 오른쪽 설정 패널에서 아이콘 표시 여부·아이콘·배경색을 수정할 수 있도록 추가.
|
||||
- 게시물 글쓰기: 코드 블록 오른쪽 설정 패널에서 언어와 줄번호 표시 여부를 수정할 수 있도록 추가.
|
||||
- 게시물 글쓰기: 토글 블록 오른쪽 설정 패널에서 기본 펼침·닫힘 상태를 선택할 수 있도록 추가.
|
||||
- 콘텐츠 파일 카드: 파일명 반복 표시를 줄이고 제목에는 확장자 포함 파일명을, 보조 영역에는 용량 중심 정보를 표시하도록 수정.
|
||||
|
||||
## v1.5.36
|
||||
|
||||
- 관리자 사이트 설정: 메인 화면 아래 브랜드 컬러 설정 카드 추가.
|
||||
- 공개 화면: 사이트 설정의 브랜드 컬러를 `--site-accent`에 반영하도록 수정.
|
||||
- 게시물 글쓰기: 인용문(`>`) 블록에서도 배경색 프리셋을 선택할 수 있도록 추가.
|
||||
- DB: `site_settings.brand_color` 컬럼 추가.
|
||||
|
||||
## v1.5.35
|
||||
|
||||
- 관리자 대시보드: 검색·SNS·직접·기타 유입 정보 카드 추가.
|
||||
- 관리자 대시보드: 디바이스·OS별 방문 집계와 유입 키워드 목록 추가.
|
||||
- 관리자 대시보드: 인기 게시물에 월간 조회수와 작성일 표시 추가.
|
||||
- Analytics: 페이지뷰 수집 시 referrer·현재 URL을 받아 유입원과 키워드를 집계하도록 추가.
|
||||
- DB: `analytics_traffic_daily`와 유입 중복 방문자 집계 컬럼 추가.
|
||||
|
||||
## v1.5.34
|
||||
|
||||
- 공개 화면: Nuxt 전역 404/오류 페이지 추가.
|
||||
- 사이트 설정: `ads.txt`, 공통 헤더 코드, 공통 푸터 코드 입력·저장 추가.
|
||||
- 공개 응답: `/ads.txt` text/plain 응답과 공개 HTML 헤더·푸터 코드 삽입 플러그인 추가.
|
||||
- 외부 위젯: gethomepage customapi 연동용 `/api/homepage-widget` 요약 API 추가.
|
||||
- DB: `site_settings.ads_txt`, `custom_head_code`, `custom_footer_code` 컬럼 추가.
|
||||
|
||||
## v1.5.33
|
||||
|
||||
- 관리자 사이트 설정: 최근 내보내기 작업이 준비 완료 상태일 때 상단 상태 배지를 숨기도록 수정.
|
||||
|
||||
## v1.5.32
|
||||
|
||||
- 관리자 사이트 설정: 최근 내보내기 작업 카드에서 요청일·목표 용량·분할 설정 표시를 제거하고 만료일만 표시하도록 정리.
|
||||
- 관리자 사이트 설정: 완료된 내보내기 작업은 진행도 영역을 숨기고 대기·생성 중 작업에서만 진행도를 표시하도록 수정.
|
||||
- 관리자 사이트 설정: 내보내기 파일 목록을 체크 선택 방식으로 바꾸고 전체 선택·선택 파일 다운로드 기능 추가.
|
||||
|
||||
## v1.5.31
|
||||
|
||||
- 관리자 사이트 설정: 게시물 내보내기 요청 설정 카드와 최근 내보내기 작업 다운로드 카드를 분리.
|
||||
- 관리자 사이트 설정: 최근 내보내기 작업은 작업이 있을 때만 별도 카드로 표시하도록 수정.
|
||||
|
||||
## v1.5.30
|
||||
|
||||
- 관리자 사이트 설정: 메인 화면 커버 읽기 모드 프리뷰가 카드 너비를 벗어나던 레이아웃 수정.
|
||||
- 관리자 사이트 설정: 게시물 Import/Export 통합 영역을 `게시물 내보내기`와 `게시물 가져오기` 독립 카드로 분리.
|
||||
- 관리자 사이트 설정: 게시물 가져오기는 ZIP 선택 후 `적용` 버튼을 눌러 실행되도록 수정.
|
||||
- 관리자 사이트 설정: 내보내기·가져오기 상세 조작은 각 카드 버튼을 눌렀을 때만 펼쳐지도록 정리.
|
||||
|
||||
## v1.5.29
|
||||
|
||||
- 관리자 사이트 설정: 메인 화면 커버를 라이트모드·다크모드 상하 개별 프리뷰로 표시하도록 수정.
|
||||
- 관리자 사이트 설정: 커버 이미지가 비어 있는 모드는 점선 드롭존으로 표시하고 파일 선택·드래그 앤 드롭 업로드를 지원하도록 추가.
|
||||
- 관리자 사이트 설정: 타임존 준비 중 섹션을 제거하고 `기타 설정` 명칭을 `사이트 정보`로 변경.
|
||||
|
||||
## v1.5.28
|
||||
|
||||
- 게시물 Import: Obsidian에서 흔히 쓰는 YAML 블록 배열(`tags:\n- tag`) 형태를 읽을 수 있도록 frontmatter 파서 보강.
|
||||
- 게시물 Import: ZIP 내부에서 찾지 못한 자산을 조용히 무시하지 않고 Import 결과 경고로 반환하도록 수정.
|
||||
- 관리자 사이트 설정: Import 완료 후 누락 자산 등 경고 목록을 표시하도록 수정.
|
||||
|
||||
## v1.5.27
|
||||
|
||||
- 게시물 Import: Export ZIP을 업로드해 Markdown frontmatter 기반 게시물로 복원하는 관리자 API 추가.
|
||||
- 게시물 Import: ZIP 내부 `images/`·`files/` 자산을 `/uploads/posts/YYYY/MM/`로 저장하고 본문 경로를 새 URL로 재매핑하도록 추가.
|
||||
- 게시물 Import: 기존 슬러그와 충돌하면 덮어쓰지 않고 `-2`, `-3` 식 새 슬러그로 가져오도록 정리.
|
||||
- 관리자 사이트 설정: Import 패널에서 ZIP 선택, Import 진행 상태, 완료 요약을 표시하도록 수정.
|
||||
|
||||
## v1.5.26
|
||||
|
||||
- 관리자 사이트 설정: 게시물 Import/Export 섹션 기본 화면을 요청 버튼과 최근 작업 중심으로 축소.
|
||||
- 관리자 사이트 설정: Export 범위·분할 설정은 `Export 요청` 버튼을 눌렀을 때만 펼쳐지도록 수정.
|
||||
- 관리자 사이트 설정: Import 영역은 `Import 하기` 버튼을 눌렀을 때 접힌 안내 패널로 표시하도록 정리.
|
||||
- 관리자 사이트 설정: 구역 이동·Export 범위 셀렉트의 화살표 SVG와 오른쪽 간격 통일.
|
||||
|
||||
## v1.5.25
|
||||
|
||||
- 게시물 Export: 분할 ZIP 계획을 고정 100개 단위가 아니라 목표 ZIP 용량 기준으로 나누도록 수정.
|
||||
- 게시물 Export: 설정 화면에서 목표 ZIP 용량과 ZIP당 최대 게시물 수를 지정할 수 있도록 추가.
|
||||
- 게시물 Export: 작업 레코드에 `max_file_size_bytes`, `error_detail` 저장 추가.
|
||||
- 게시물 Export: 실패 작업 카드에서 상세 오류 원인을 확인할 수 있도록 추가.
|
||||
|
||||
## v1.5.24
|
||||
|
||||
- 게시물 Export: 준비 완료 분할 파일을 브라우저에서 순차적으로 일괄 다운로드하는 버튼 추가.
|
||||
- 게시물 Export: 실패한 작업을 준비 완료 파일은 유지한 채 실패 지점부터 재시도할 수 있도록 추가.
|
||||
- 게시물 Export: 완료 시 Resend 설정이 있으면 요청 관리자 이메일로 준비 완료 알림을 보내도록 추가.
|
||||
- 게시물 Export: 만료된 완료·실패 작업과 생성 ZIP 파일을 작업 목록 조회·새 요청 시 자동 정리하도록 추가.
|
||||
|
||||
## v1.5.23
|
||||
|
||||
- 게시물 Export: 전체·특정년·특정월·직접 지정 날짜 범위 백업 요청 추가.
|
||||
- 게시물 Export: 작업 레코드에 `date_from`, `date_to`, `range_label` 저장 추가.
|
||||
- 게시물 Export: 날짜 범위 기준 게시물만 분할 ZIP 대상에 포함하도록 수정.
|
||||
- 관리자 사이트 설정: Export 요청 카드에 범위 선택 UI 추가.
|
||||
- 관리자 사이트 설정: 완료·실패한 Export 작업과 생성 ZIP 파일을 삭제할 수 있도록 추가.
|
||||
|
||||
## v1.5.22
|
||||
|
||||
- 게시물 Export: 대기 작업을 백그라운드에서 실제 ZIP 파일로 생성하는 서버 실행부 추가.
|
||||
- 게시물 Export: 게시물별 폴더와 `제목.md`, 내부 `images/`·`files/` 자산 폴더를 ZIP에 포함하도록 추가.
|
||||
- 게시물 Export: 내부 `/uploads` 이미지·파일 URL을 ZIP 내부 상대 경로로 재작성하도록 추가.
|
||||
- 게시물 Export: 준비 완료된 분할 ZIP 파일을 관리자 다운로드 API로 내려받을 수 있도록 추가.
|
||||
- 관리자 사이트 설정: 대기 중·생성 중 Export 작업이 있으면 Export 요청 버튼을 비활성화하도록 수정.
|
||||
|
||||
## v1.5.21
|
||||
|
||||
- 게시물 Export: 작업 진행 확인용 `processed_count`, `current_part_index`, `progress_message`, `started_at` 컬럼 추가.
|
||||
- 관리자 사이트 설정: Export 작업 카드에 진행 숫자, 진행률 바, 진행 설명, 마지막 갱신 시각 표시 추가.
|
||||
- 관리자 사이트 설정: 대기 중·생성 중 Export 작업이 있으면 작업 목록을 5초 간격으로 자동 새로고침하도록 수정.
|
||||
- 게시물 Export: 실제 zip 생성 워커가 붙으면 `1201 / 30002` 같은 진행도가 표시되도록 기반 정리.
|
||||
|
||||
## v1.5.20
|
||||
|
||||
- 게시물 Export: 백그라운드 작업 요청용 `post_export_jobs`와 분할 파일 계획용 `post_export_files` 테이블 추가.
|
||||
- 게시물 Export: 관리자 API에서 최근 Export 작업 목록 조회와 새 Export 작업 요청을 지원하도록 추가.
|
||||
- 관리자 사이트 설정: 게시물 Import/Export 섹션에서 Export 요청, 최근 작업, 분할 파일 계획을 확인할 수 있도록 수정.
|
||||
- 게시물 Export: zip 생성 워커·이메일 알림·다운로드 연결 전 1차 작업 등록 단계로 정리.
|
||||
|
||||
## v1.5.19
|
||||
|
||||
- 게시물 Export: 대용량 게시물 환경을 고려해 백그라운드 분할 생성, 이메일 완료 알림, 분할/일괄 다운로드, 100일 보존 정책 방향 문서화.
|
||||
|
||||
## v1.5.18
|
||||
|
||||
- 게시물 Import/Export: 백업 목적에 맞게 게시물별 폴더와 로컬 자산 폴더를 포함하는 Obsidian 호환 번들 구조로 방향 수정.
|
||||
|
||||
## v1.5.17
|
||||
|
||||
- 관리자 사이트 설정: 읽기 모드의 POST·어나운스 토글을 켜진 상태여도 조작 불가로 보이도록 비활성 톤 강화.
|
||||
- 게시물 Import/Export: Obsidian 호환 Markdown frontmatter 기반 1차 구현 방향 문서화.
|
||||
|
||||
## v1.5.16
|
||||
|
||||
- 공개 게시글 TOC: 본문 스크롤 위치에 맞춰 현재 제목 항목을 시각적으로 강조하도록 수정.
|
||||
- 공개 게시글 TOC: 목차 항목이 많을 때 TOC 영역이 자체 스크롤되며 활성 항목을 자동으로 따라가도록 수정.
|
||||
- 공개 댓글: 댓글·답글 등록 버튼이 입력값이 있을 때만 활성화되도록 수정.
|
||||
- 공개 댓글: 정렬 라벨 한글화와 답글 입력 영역 스타일 정리 사용자 편집분 반영.
|
||||
|
||||
## v1.5.15
|
||||
|
||||
- 공개 헤더: 비로그인 상태의 사용자 메뉴 기본 아바타도 `?` 텍스트 대신 사람 아이콘으로 표시하도록 수정.
|
||||
|
||||
## v1.5.14
|
||||
|
||||
- 공개 헤더: 로그인 사용자의 아바타 이미지가 없을 때 이니셜 대신 사람 아이콘을 표시하도록 수정.
|
||||
- 공개 게시글 상세: 모바일처럼 오른쪽 사이드바가 하단으로 내려가는 화면에서는 TOC를 숨기도록 수정.
|
||||
- 관리자 미디어: 검색창을 글·멤버 검색과 같은 아이콘 포함 입력 스타일로 통일.
|
||||
- 관리자 미디어: 미디어 라이브러리에서 파일 직접 추가 버튼을 추가하고, 현재 폴더 업로드 시 해당 폴더로 배치되도록 수정.
|
||||
- 관리자 미디어: 현재 검색·필터 결과 기준 전체 선택과 선택 삭제 기능 추가.
|
||||
|
||||
## v1.5.13
|
||||
|
||||
- 공개 게시글 TOC: 목차 클릭 시 기본 해시 점프 대신 부드러운 스크롤로 이동하도록 수정.
|
||||
- 공개 게시글 제목 앵커: 고정 상단 헤더 높이와 여백을 반영한 scroll margin으로 이동 위치를 안정화.
|
||||
|
||||
## v1.5.12
|
||||
|
||||
- 공개 게시글 상세: 오른쪽 사이드바의 Recommended 영역을 숨기고, 같은 위치에 H1~H3 기반 TOC를 표시하도록 수정.
|
||||
- 공개 게시글 본문: H1~H3 제목에 TOC 링크용 앵커 ID를 부여하도록 수정.
|
||||
- DB: 로컬 개발 DB에서 `zenn` 계정을 소유자로 보정하고 기존 비활성 소유자 계정을 일반 멤버로 정리.
|
||||
|
||||
## v1.5.11
|
||||
|
||||
- 관리자 멤버 상세: 기존 회원 진입 시 읽기 전용 보기 모드로 표시하고 상단 버튼을 `수정하기`로 변경.
|
||||
- 관리자 멤버 상세: 수정 모드에서만 입력 필드·셀렉트를 편집 컨트롤로 표시하고, 변경 사항이 있을 때만 저장 버튼을 활성화하도록 수정.
|
||||
- 관리자 멤버 상세: 저장·업로드·비밀번호 변경 오류/성공 피드백을 우측 상단 토스트로 통일.
|
||||
- 관리자 멤버 목록: 검색창 스타일을 글 목록 검색창과 같은 테두리형 입력으로 통일.
|
||||
|
||||
## v1.5.10
|
||||
|
||||
- 관리자 멤버 상세: 소유자 본인, 관리자끼리 등 변경 불가한 등급 셀렉트는 UI에서 비활성화하도록 수정.
|
||||
- 관리자 멤버 상세: 등급 변경 권한이 없는 세션에서는 멤버 등급을 일반 텍스트로 표시하도록 수정.
|
||||
- 관리자 글 목록: 제목·슬러그·요약·본문·태그 기준 검색 입력 추가.
|
||||
- 관리자 글 목록: 필터 셀렉트 화살표를 공통 SVG 아이콘과 오른쪽 여백으로 통일.
|
||||
- 관리자 글 목록: 추천 표시와 제목 사이에 대표 이미지 썸네일 열 추가.
|
||||
- 관리자 페이지 작성: HTML 문서 모드에서 빈 본문 또는 `!` 입력 후 Tab으로 기본 HTML 골격을 자동 완성하도록 추가.
|
||||
|
||||
## v1.5.9
|
||||
|
||||
- 관리자 대시보드: 페이지별 조회·방문자·체류·스크롤 통계 수집 및 인기 페이지 목록 추가.
|
||||
- 공개 HTML 문서 페이지: Nuxt 클라이언트 통계를 거치지 않는 원문 HTML 응답도 서버에서 페이지 조회를 기록하도록 수정.
|
||||
- 관리자 네비게이션 추천 사이트: 대체 텍스트와 썸네일 URL 입력 추가.
|
||||
- 공개 오른쪽 사이드바 추천 사이트: 대체 텍스트가 있으면 URL 대신 표시하고, 썸네일이 있으면 파비콘 대신 표시하도록 수정.
|
||||
- DB: 페이지 통계 테이블과 추천 사이트 메타데이터 컬럼 추가.
|
||||
|
||||
## v1.5.8
|
||||
|
||||
- 관리자 멤버 권한 변경: 소유자가 본인 권한을 직접 낮출 수 없도록 수정.
|
||||
- 관리자 멤버 목록: 상태 열에 멤버 등급을 표시하고, 비활성 회원만 보조 상태로 표시하도록 수정.
|
||||
- DB: 소유자가 없는 상태일 때 기존 관리자 중 가장 오래된 계정을 소유자로 복구하는 마이그레이션 추가.
|
||||
|
||||
## v1.5.7
|
||||
|
||||
- 관리자 페이지 작성/수정: 일반 텍스트 모드에서도 페이지 형식 선택창이 계속 보이도록 수정.
|
||||
- 관리자 페이지 작성/수정: HTML 자산 업로드는 HTML 문서 모드에서만 보이도록 수정.
|
||||
- 회원 활동 IP 기록: 로그인·회원가입·댓글·좋아요·이메일 OTP 요청에서 프록시 헤더(`x-forwarded-for`)를 포함해 요청 IP를 저장하도록 수정.
|
||||
|
||||
## v1.5.6
|
||||
|
||||
- 관리자 멤버 상세: 멤버 등급 선택이 즉시 저장되지 않고 저장 버튼으로만 반영되도록 수정.
|
||||
- 회원 권한 변경 API: 소유자·관리자만 권한 변경 가능하도록 규칙 강화.
|
||||
- 회원 권한 변경 API: 관리자는 다른 관리자·소유자를 조작할 수 없고, 소유자·관리자 등급을 부여할 수 없도록 수정.
|
||||
- 회원 권한 변경 API: 마지막 소유자 권한이 사라지지 않도록 트랜잭션 잠금 안에서 검증하도록 수정.
|
||||
|
||||
## v1.5.5
|
||||
|
||||
- 회원 권한에 `VIP` 등급 추가.
|
||||
- 공개 게시물 API: 멤버십 글은 로그인 여부가 아니라 VIP 이상 등급(`vip`/`admin`/`owner`)일 때만 조회되도록 수정.
|
||||
- 관리자 멤버 상세: 멤버 등급 선택 UI 추가.
|
||||
- 관리자 글쓰기: 멤버십 상태 안내 문구를 VIP 이상 공개 기준으로 수정.
|
||||
- 관리자 미디어: 사용자 편집분을 포함해 썸네일 탭 문구를 프로필 이미지 기준으로 정리.
|
||||
- DB: `users.user_role` 제약에 `vip`를 허용하는 마이그레이션 추가.
|
||||
|
||||
## v1.5.4
|
||||
|
||||
- 게시물 상태: `멤버십`, `비공개` 상태 추가.
|
||||
- 공개 게시물 API: 멤버십 글을 공개 기본 목록에서 분리하도록 수정.
|
||||
- 공개 게시물 API: 비공개 글은 사용자 화면과 공개 API에서 숨기도록 정리.
|
||||
- 관리자 글쓰기: 상태 선택·툴바·목록 필터에 멤버십/비공개 상태 반영.
|
||||
- 고정 페이지 상태: `초안`, `공개`, `비공개` 상태 추가.
|
||||
- 공개 페이지 API/HTML 문서 응답: 공개 상태 페이지에서만 접근되도록 수정.
|
||||
- 관리자 페이지 작성/목록: 페이지 상태 선택과 목록 상태 표시 추가.
|
||||
- DB: 게시물·페이지 공개 상태 제약을 확장하는 마이그레이션 추가.
|
||||
|
||||
## v1.5.3
|
||||
|
||||
- 관리자 페이지 작성/수정: 새 페이지 기본 형식을 HTML 문서 모드로 변경.
|
||||
- 관리자 페이지 작성/수정: 페이지 슬러그도 게시글처럼 한글 제목을 영문 슬러그로 자동 변환하도록 수정.
|
||||
- 관리자 페이지 작성/수정: 페이지 형식의 `기본` 라벨을 `일반 텍스트`로 변경.
|
||||
- 관리자 페이지 작성/수정: 대표 이미지 UI 제거 및 저장 시 페이지 대표 이미지를 사용하지 않도록 정리.
|
||||
- 관리자 페이지 작성/수정: HTML 자산 업로드 버튼 추가, 업로드된 파일 URL을 HTML textarea 현재 커서 위치에 삽입하도록 추가.
|
||||
- DB: `pages.render_mode` 기본값을 `html_document`로 변경하는 마이그레이션 추가.
|
||||
|
||||
## v1.5.2
|
||||
|
||||
- 관리자 페이지 작성/수정: 게시글 작성 화면과 같은 전체 화면 에디터, 상단 저장 툴바, 접이식 오른쪽 설정 패널 구조로 변경.
|
||||
- 관리자 페이지 작성/수정: 페이지 형식 선택, Page URL, 대표 이미지, 삭제 액션을 오른쪽 설정 패널로 이동.
|
||||
- 관리자 페이지 작성/수정: 기본 모드 본문 에디터를 게시글과 같은 Markdown-first 에디터로 통일.
|
||||
- 관리자 레이아웃: 페이지 작성/수정 경로도 전체 화면 에디터 모드로 처리하도록 수정.
|
||||
|
||||
## v1.5.1
|
||||
|
||||
- 고정 페이지: 기본 Markdown 모드와 원문 HTML 문서 모드 선택 추가.
|
||||
- 관리자 페이지 작성: HTML 문서 모드에서 전체 HTML 붙여넣기용 코드형 textarea 추가.
|
||||
- 공개 페이지: HTML 문서 모드 페이지는 `/pages/:slug`에서 저장된 HTML을 `text/html` 원문으로 응답하도록 추가.
|
||||
- DB: `pages.render_mode` 컬럼과 `markdown`/`html_document` 제약 추가.
|
||||
|
||||
## v1.5.0
|
||||
|
||||
- 관리자 글 목록: 태그 컬럼에 첫 번째 태그만 대표 태그로 표시하도록 수정.
|
||||
- 관리자 글쓰기: 태그 입력 오른쪽 트리거를 기존 태그 제거가 아닌 메인 태그 선택 드롭다운으로 동작하도록 수정.
|
||||
- 관리자 글쓰기: 기존 태그 부분 검색과 방향키·Enter 선택 추가.
|
||||
- 관리자 글 목록·글쓰기: 태그 배지에 태그별 고유 색상 반영.
|
||||
- 관리자 글쓰기: 게시물 설정 사이드바의 상태·태그 드롭다운 화살표와 닫기 버튼 아이콘 정리.
|
||||
- 관리자 글·페이지 목록: 마지막 행 더보기 메뉴가 테이블 영역에 잘리던 문제 수정.
|
||||
|
||||
## v1.4.7
|
||||
|
||||
- 관리자 글쓰기 라이브 모드: 문단 이동(방향키) 시 굵게·기울임 등 인라인 마크다운이 사라지던 문제 수정.
|
||||
- 콘텐츠 렌더러: 인용(`>`) 블록에 `> [!bg=색상]` 옵션 줄을 추가해 콜아웃과 같은 배경 프리셋 지정 지원.
|
||||
- 관리자 글쓰기: 소스 모드에서 라이브 모드로 전환할 때 현재 커서 줄을 라이브 화면 중앙에 가깝게 스크롤하도록 보정.
|
||||
|
||||
## v1.4.6
|
||||
|
||||
- 관리자 설정: 로고 업로드가 저장 버튼 없이 즉시 DB에 반영되던 흐름을 파일 업로드 후 저장 버튼으로 반영하도록 수정.
|
||||
- 관리자 설정: 메인 화면 커버에 다크모드 전용 이미지 필드 추가.
|
||||
- 공개 홈: `HomeHero`가 라이트·다크 커버 이미지를 테마에 맞춰 교체 표시하도록 수정.
|
||||
- DB: `site_settings.home_cover_dark_image_url` 컬럼 추가.
|
||||
|
||||
## v1.4.5
|
||||
|
||||
- 공개 게시글 상세: 게시물 메타 영역의 수정 시각 표시 제거, 글쓴이용 편집 링크는 요청 SVG 아이콘으로 교체.
|
||||
- 공개 목록: 홈 Latest·게시물 목록·태그 목록에서 요약이 비어 있으면 본문에서 짧은 설명을 생성해 표시.
|
||||
- 공개 목록: 목록용 설명은 수동 말줄임을 붙이지 않고 전용 `post-summary-clamp` 2줄/3줄 클래스가 실제 표시 줄 끝에서 처리하도록 보정.
|
||||
- `composables/createPostSummary.js`: 게시물 요약·본문 fallback 공통 유틸 추가, 빈 단락 마커 HTML 주석은 요약에서 제거.
|
||||
- 게시물 작성자: `posts.author_id` 마이그레이션 추가 및 owner/admin 단일 계정 환경에서만 기존 글 작성자 backfill.
|
||||
- 관리자 게시물 저장: 새 글 작성 시 현재 관리자 세션 사용자 ID를 작성자로 저장하고, 기존 글 작성자가 비어 있으면 수정자 ID로 보정.
|
||||
- 공개 게시글 상세: 관리자 여부가 아니라 로그인 회원 ID와 게시물 작성자 ID가 같을 때만 새 탭 편집 링크 표시.
|
||||
- 공개 게시글 상세: 요약이 비어 있으면 본문에서 짧은 텍스트를 만들어 SEO·공유 설명에 사용.
|
||||
- 회원 세션 API: 공개 화면 권한 분기를 위해 `isAdmin`, `role` 응답 필드 추가.
|
||||
|
||||
## v1.4.3
|
||||
|
||||
- `HomeHero`: 오버레이 본문 줄바꿈(`\n`)이 미리보기·홈에서도 보이도록 `whitespace-pre-line` 적용.
|
||||
- 관리자 설정: 메인 화면 커버 편집 모드에서도 미리보기를 버튼 영역과 분리해 카드 폭 기준으로 크게 표시.
|
||||
- 관리자 설정: 메인 화면 커버 미리보기를 실제 `HomeHero` 오버레이 구조로 표시해 긴 텍스트 줄바꿈도 공개 화면과 동일하게 확인하도록 수정.
|
||||
- 관리자 설정: 메인 화면 커버 이미지 미리보기에서 이미지 폭만 360px로 제한되어 오른쪽이 비어 보이던 표시 문제 수정.
|
||||
- 배포 TODO: 다음달 작업 대상으로 PostgreSQL·업로드 파일 백업/복구 시스템 설계 및 구현 항목 추가.
|
||||
- 관리자 레이아웃: 공개 다크모드가 글쓰기 에디터 입력에 영향을 주지 않도록 라이트 폼 보정 스코프 축소.
|
||||
- 관리자 레이아웃: 대시보드 메뉴를 `/admin` 활성 링크로 연결.
|
||||
- 관리자 로그인: 일반 로그인과 동일한 다크 인증 스타일, 폼을 화면 오른쪽·내부 오른쪽 정렬.
|
||||
- 관리자 대시보드: 통계 추이 막대 차트 높이·툴팁·기간별 묶음 집계 보정.
|
||||
- 홈 Latest: 컴팩트 기본 보기, 리스트/컴팩트 표시 조건 수정, Featured·Latest UI 보정.
|
||||
- 관리자 미디어: 종류 필터(전체·이미지·영상·음악·파일), 미사용 필터, 비디오 프레임 썸네일 추가.
|
||||
- 배포 문서: NAS 운영 업데이트 절차 추가.
|
||||
|
||||
## v1.4.2
|
||||
|
||||
- 관리자 글쓰기: 소스 모드 긴 문장 자동 줄바꿈 시 라인 번호 높이를 실제 wrap 높이에 맞춰 보정.
|
||||
- 관리자 글쓰기: 라이브 모드에서 소스 모드로 전환할 때 현재 포커스·화면 위치에 가까운 원본 줄로 커서와 스크롤 복원.
|
||||
- 관리자 글쓰기: 소스 모드에서 라이브 모드로 전환할 때 현재 textarea 커서 줄을 라이브 편집 블록에 포커스하도록 보정.
|
||||
- 콘텐츠 렌더러: 단독 이미지 파일 URL은 임베드가 아니라 이미지 블록으로 렌더링하도록 보정.
|
||||
- 관리자 글쓰기: 라이브 모드 이미지 블록 hover/focus 시 편집·삭제 버튼 표시, 편집 버튼으로 기존 오른쪽 이미지 설정 패널 연결.
|
||||
- `ProseImage`: URL 비어 있음·로드 실패 시 최소 높이 placeholder와 오류 안내 표시.
|
||||
- 관리자 라이브 미리보기: 이미지 블록을 다른 이미지 블록에 드롭하면 `:::gallery`로 병합(`merge-images-to-gallery`).
|
||||
- 관리자 라이브 미리보기: 갤러리 이미지를 블록 사이 삽입선에 드롭하면 단일 이미지 블록으로 분리(`extract-gallery-image`), 갤러리 1장 남으면 단일 이미지로 정리·0장이면 제거.
|
||||
- 관리자 라이브 미리보기: 단독 이미지 URL 줄도 이미지 드래그 병합·갤러리 추가 대상으로 인식하도록 보정.
|
||||
- 관리자 라이브 미리보기: 단일 이미지 블록을 기존 갤러리 셀에 드롭해 갤러리에 추가(`insert-image-to-gallery`).
|
||||
- 콘텐츠 렌더러: 갤러리를 최대 3개 단위 행으로 나누고 이미지 자연 비율에 따라 셀 너비를 조정.
|
||||
- 관리자 라이브 미리보기: 갤러리 블록도 포커스·방향키 이동·편집/삭제 버튼 접근이 가능하도록 보정.
|
||||
- 관리자 라이브 미리보기: 갤러리 전체 편집/삭제 버튼을 개별 이미지 편집/삭제 버튼으로 변경.
|
||||
- 관리자 글쓰기: 블록 설정 패널 바깥 클릭 시 닫힘, 갤러리 이미지 추가 미디어 모달 중 패널 유지.
|
||||
- 콘텐츠 렌더러: 다크모드 기본 인용 블록 텍스트를 어두운 색으로 고정해 가독성 보정.
|
||||
- 콘텐츠 렌더러: 공개 본문 리스트 번호·점 색상을 글쓰기 화면과 같은 파란 계열로 통일.
|
||||
- 공개 레이아웃: 다크모드에서 좌우 사이드바 배경을 홈페이지 기본 배경(`--site-bg`)과 동일하게 통일.
|
||||
- 배포 문서: NAS 운영 업데이트 절차(`git pull` → DB 마이그레이션 → `docker compose up -d --build`) 추가.
|
||||
- 홈 Latest: 기본·Default 보기 방식이 컴팩트 형태로 보이도록 리스트/컴팩트 썸네일 표시 조건 수정.
|
||||
- 홈 Featured: 대표 이미지 없는 추천 글 썸네일을 게시물 제목 placeholder로 통일.
|
||||
- 홈 Latest: 게시물이 적을 때 보기 방식 메뉴가 잘리지 않도록 섹션 최소 높이 추가.
|
||||
- 관리자 로그인: 일반 로그인과 같은 다크 인증 스타일을 적용하고, 관리자 구분용으로 폼을 오른쪽 배치 및 내부 오른쪽 정렬.
|
||||
- 관리자 대시보드: 통계 추이 막대 차트가 보이지 않던 CSS 높이 비율 문제 수정.
|
||||
- 관리자 대시보드: 통계 추이 막대 hover/focus 시 날짜와 정확한 값을 표시하는 툴팁 추가.
|
||||
- 관리자 대시보드: 30일 이상 통계 추이 차트는 기간별 묶음 집계로 표시해 카드 폭 넘침 방지.
|
||||
- 관리자 레이아웃: 사이드바 대시보드 항목을 비활성 표시에서 `/admin` 활성 링크로 변경.
|
||||
- 관리자 레이아웃: 공개 사이트 다크모드가 관리자 입력·패널 색상에 영향을 주지 않도록 `admin-layout` 라이트 테마 스코프 추가.
|
||||
- 관리자 글쓰기: 라이트 폼 컨트롤 보정이 글쓰기 에디터 입력까지 덮지 않도록 스코프 축소.
|
||||
- 관리자 미디어: 미디어 라이브러리에 전체·이미지·영상·음악·파일 종류 필터 추가.
|
||||
- 관리자 미디어: 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 미사용 미디어만 보는 필터 추가.
|
||||
- 관리자 미디어: 비디오 항목은 초반 프레임을 브라우저에서 추출해 목록 썸네일로 표시.
|
||||
|
||||
## v1.4.1
|
||||
|
||||
- 관리자 미디어 업로드: 이미지·비디오·오디오·문서별 최대 크기 한도 분리(`MAX_VIDEO_FILE_SIZE` 등). 기본 비디오 200MB.
|
||||
- 관리자 글쓰기: 업로드 크기 초과·API 413 시 토스트 오류 표시, 미디어 모달에 최대 용량 안내 추가.
|
||||
- `lib/upload-size-limit.js`: 클라이언트 번들에서 `node:path` 제거(500·hydration 경고 수정).
|
||||
- 임베드 저장 형식: 새 임베드 삽입·라이브 편집·레거시 블록 변환 시 `:::embed` 대신 단독 URL 한 줄로 저장하도록 통일, 단독 URL 줄도 블록 설정 패널 대상으로 인식.
|
||||
- 관리자 글쓰기: 라이브 모드 임베드를 URL 입력 카드 없이 즉시 프리뷰로 표시, 임베드·비디오·오디오·파일 카드 삭제 버튼과 방향키 이동·삭제·아래 줄 추가 키보드 조작 지원.
|
||||
- 관리자 글쓰기: 라이브 모드 제목 Enter 시 현재 제목 저장 후 아래 빈 문단 추가, 원문 편집 상태로 전환되던 흐름 수정.
|
||||
- 관리자 글쓰기: 게시물 상단 제목 Enter 시 소스 모드 강제 전환 제거, 라이브 모드 상단 스크롤·본문 첫 줄 포커스 보정.
|
||||
|
||||
## v1.4.0
|
||||
|
||||
- 콘텐츠 렌더러: `:::video`, `:::audio`, `:::file` fenced block 파싱과 `ProseVideo`·`ProseAudio`·`ProseFile` 카드 렌더링 연결.
|
||||
- 관리자 글쓰기: 슬래시 명령에 비디오·오디오·파일 블록 템플릿 추가.
|
||||
- 임베드: X/Twitter iframe 폭·높이·외곽 여백 보정, Mastodon 공개 게시물 `/embed` 렌더링과 postMessage 기반 자동 높이 조절 추가.
|
||||
- 관리자 글쓰기: 라이브/스타일 모드의 `:::embed` 블록에 URL 입력 카드와 적용 미리보기 전환 버튼 표시.
|
||||
- 관리자 글쓰기: `/video`, `/audio`, `/file` 슬래시 명령을 미디어 선택·업로드 모달과 연결해 업로드 후 URL 자동 채우기 지원.
|
||||
- 콘텐츠 렌더러: 단독 `http(s)` URL 한 줄을 `:::embed` 블록과 동일하게 자동 임베드 렌더링.
|
||||
- 미디어 라이브러리: 이미지 외 비디오·오디오·문서 파일 업로드와 목록 표시 지원.
|
||||
|
||||
## v1.3.9
|
||||
|
||||
- NAS 마이그레이션: `psql`이 while 루프 stdin을 소비해 001만 처리되던 `migrate-production-db.sh` 버그 수정.
|
||||
|
||||
## v1.3.8
|
||||
|
||||
- NAS 마이그레이션: `.env.production`이 없을 때 `.env` 또는 실행 중 DB 컨테이너 환경 변수로 동작하도록 `migrate-production-db.sh` 보정.
|
||||
|
||||
## v1.3.7
|
||||
|
||||
- NAS 마이그레이션: npm 없는 NAS 호스트에서도 실행 가능한 `scripts/migrate-production-db.sh` 추가.
|
||||
- 운영 문서: `db:migrate:prod:*` 안내를 `sh scripts/migrate-production-db.sh` 기준으로 수정.
|
||||
|
||||
## v1.3.6
|
||||
|
||||
- DB 마이그레이션: `schema_migrations` 적용 이력 관리와 `db:migrate:prod`, `db:migrate:prod:status`, `db:migrate:prod:baseline` 명령 추가.
|
||||
- 운영 보호: 기존 운영 스키마가 있는데 적용 이력이 없으면 001부터 자동 실행하지 않고 baseline 안내 후 중단.
|
||||
|
||||
## v1.3.5
|
||||
|
||||
- 관리자 로그인: 자동완성 값 동기화·제출 중만 버튼 비활성. 운영 HTTP에서 Secure 쿠키 미저장으로 로그인 루프되던 문제 수정. `server/utils/session-cookie.js`로 path `/`·`x-forwarded-proto` Secure 통일.
|
||||
- 통계: `lib/analytics-shared.js` 분리로 클라이언트 `node:crypto` 오류 수정. 통계 조회 시작일 JS 계산으로 `date >= integer` 오류 수정.
|
||||
- 관리자 대시보드: 상단 요약 한 줄·7일~12개월 차트·접속자 목록(게시물 제목·유지시간). `trends` 일자별 0 채움.
|
||||
- 통계 보관: 일별 집계 누적 보관, 방문자 해시 32일 초과 정리, 실시간 세션 90초 TTL.
|
||||
|
||||
## v1.3.4
|
||||
|
||||
- 통계 확장: 체류시간·스크롤 구간(25/50/75/100%)·실시간 접속 세션. 마이그레이션 `031_analytics_engagement_and_realtime.sql`.
|
||||
- `POST /api/analytics/heartbeat`, `GET /admin/api/analytics/realtime`. 로그인 사용자는 닉네임·아바타·현재 경로로 접속자 목록 표시.
|
||||
- 관리자 대시보드: 현재 접속자·평균 체류·50% 스크롤·실시간 접속자 목록·인기 글 참여 지표 추가.
|
||||
|
||||
## v1.3.3
|
||||
|
||||
- 자체 최소 통계: 일별 익명 방문자 해시·사이트/게시물 일별 집계. 마이그레이션 `030_analytics_daily_stats.sql`. 원문 IP·User-Agent 미저장.
|
||||
- `POST /api/analytics/pageview`, `plugins/site-analytics.client.js`(pageview·15초+50% 스크롤 read).
|
||||
- 관리자 대시보드: 오늘/7일 방문·30일 조회·인기 게시물 Top 5. `GET /admin/api/analytics/summary`, `GET /admin/api/analytics/posts`.
|
||||
|
||||
## v1.3.2
|
||||
|
||||
- 스플래시: `SITE_BRAND_LOGO_TEXT` localStorage 사용 중단·레거시 키 제거. 스플래시 문구는 사이트 제목, 이미지는 `SITE_BRAND_LOGO_URL`만 캐시.
|
||||
- 관리자 설정 내비: 타임존·메인 화면·어나운스 바·Import/Export·스팸 필터 아이콘 추가.
|
||||
- 어나운스 바: 클라이언트에서 숨김 여부 확인 후 아래로 슬라이드 인. 닫기·7일간 보지 않기 시 위로 슬라이드 아웃 후 제거(깜빡임 방지).
|
||||
- 어나운스 바: X는 이번 방문(세션)만 숨김, `7일간 보지 않기` 텍스트 버튼으로 localStorage 7일 스누즈.
|
||||
|
||||
## v1.3.1
|
||||
|
||||
- 스팸 필터: 가입 금지 닉네임(`signupBlockedUsernames`) 설정·저장. 기본 admin, master, zenn, sori, sori.studio. 회원가입·프로필 닉네임 변경 시 검사, `zenn은 사용할 수 없는 단어입니다.` 형식 안내. 마이그레이션 `029_site_settings_signup_blocked_usernames.sql`.
|
||||
|
||||
## v1.3.0
|
||||
|
||||
- 관리자 사이트 설정 좌측 내비: `AdminSettingsNavIcon`·`iconId` 슬롯 추가. 블로그 제목·설명에 type-cursor 아이콘 연결, 나머지는 placeholder 틀.
|
||||
- 관리자 POST 설정: 읽기 모드에서도 수정일 표시를 비활성화 토글 UI로 표시(켜짐/꺼짐 텍스트 제거).
|
||||
- 어나운스 바: `site_settings` 필드·마이그레이션 `028_site_settings_announcement.sql`. 공개 상단 배너(`SiteAnnouncementBar`·`SiteTopChrome`), 닫기 시 `localStorage`·설정 변경 시 재노출.
|
||||
- 관리자 설정「어나운스 바」: 사용 토글·맞춤 설정(문구·선택 링크·배경색 프리셋 검정/흰/브랜드).
|
||||
|
||||
## v1.2.9
|
||||
|
||||
- 홈 상단: Ghost형 헤딩·구독 폼 제거. 사이트 설정「메인 화면」에서 커버 이미지(720px)·오버레이 제목·본문 설정. `HomeHero.vue`, 마이그레이션 `027_site_settings_home_cover.sql`.
|
||||
- 메인 화면 설정: 커버 업로드 시 제목·본문이 리셋되지 않도록 업로드는 파일만·저장 버튼에서 이미지·텍스트 일괄 `PUT` 반영.
|
||||
- 테마 깜빡임: head 인라인 스크립트로 `data-theme` 선적용. 로딩 스플래시(`app.html`·캐시된 사이트 로고).
|
||||
- 관리자 글 목록: 휴지통 대신 more vert 행 메뉴(추천·추천 제거·삭제).
|
||||
- 관리자 태그 관리: 메인·일반 태그 모두 more vert 메뉴(수정·전환·제외·삭제).
|
||||
- 관리자 공통 `AdminRowMoreMenu`·`useAdminRowMenu`: 페이지·미디어 폴더·네비게이션 삭제 UI 통일. 태그 메뉴 항목 좌측 정렬 수정.
|
||||
- 홈 Latest 피드: List(썸네일+본문)·Compact(텍스트만)·Cards(2열) 보기 구분. 메뉴 List/Compact 선택값과 레이아웃 일치. Default 클릭 시 Compact로 전환. Cards 상단 여백·테두리 클리핑 수정.
|
||||
- 게시물 카드: 대표 이미지 없을 때 썸네일 영역에 제목 텍스트 플레이스홀더(`PostCardMedia`). 홈 Latest·태그·게시물 목록 공통.
|
||||
- 슬래시 메뉴: 키보드 ↓ 이동 시 scrollIntoView+mouseenter 충돌로 하단 항목이 반복 선택되던 문제 수정.
|
||||
- 라이브 토글: 제목 Enter→본문 포커스, ↑↓는 토글 내부만 이동(한글 조합·스크롤 점프 보정).
|
||||
- 라이브 토글: `ContentMarkdownToggleEditor`로 제목·본문 인라인 편집. `ProseToggle` 펼침·접힘 애니메이션(그리드 전환).
|
||||
- 라이브 코드 블록: Enter 줄바꿈·줄번호 반영 수정(`<br>` 읽기). 마지막 줄 ↓로 블록 이탈·다음 문단 삽입.
|
||||
- 콜아웃 설정: 이모지 7종 프리셋 선택, 직접 입력 제거. 아이콘 토글 UI 정리.
|
||||
- 코드 블록: `ProseCodeBlock.vue`로 라이브·공개 스타일 통일(`#15171a`, 줄번호 gutter). 라이브 호버 시 Language·줄번호 토글. 공개 화면 언어 라벨 옆 복사 버튼.
|
||||
- 라이브 콜아웃: 아이콘 호버·클릭 시 설정 모달(이모지·배경색).
|
||||
- 라이브 모드 코드 블록: `pre` 단일 편집 영역으로 공개 화면과 높이 맞춤, ↑↓는 블록 첫·마지막 줄에서만 밖으로 이동.
|
||||
- 라이브 모드: `- `·`> ` 마커만 있는 줄도 목록·인용 블록으로 인식. 코드·콜아웃 본문 인라인 편집 추가.
|
||||
- 슬래시 콜아웃 기본 삽입을 `:::callout emoji=💡 bg=blue` 형식으로 변경.
|
||||
- 소스(작성) 모드 textarea에 `/` 슬래시 명령 추가(미리보기와 동일). 상단 마크다운 툴바 제거.
|
||||
- 라이브·소스 슬래시: 기본 제목은 h2·h3·h4만 노출. h1은 `/h1` 검색 시에만 선택 가능(게시물 제목이 유일한 h1).
|
||||
- 라이브 모드 슬래시: h2 등 명령 적용 후 `## ` 뒤로 포커스·커서 복원. Esc는 메뉴만 닫고 줄·`/…` 입력 유지(해당 줄 메뉴 재오픈 억제). Esc 시 줄 비우기로 글이 사라지던 문제 수정.
|
||||
- 라이브 모드 슬래시 메뉴 Ghost 스타일: 아이콘+라벨만 표시, 호버·포커스 시 오른쪽 `/slug` 표시. `AdminSlashCommandIcon.vue` 추가.
|
||||
- 라이브 모드 슬래시: 미디어 모달 Esc 닫기, ↑↓ 시 목록 scrollIntoView, 마우스·키보드 하이라이트 단일화.
|
||||
- 라이브 모드 슬래시 명령: `/` 입력 시 메뉴, `/image`+Enter 이미지 삽입, ↑↓ 선택·Esc 취소. 메뉴 뷰포트 밖 넘침 시 위·좌우 보정.
|
||||
- 라이브 모드 Cmd+A 후 Backspace: 전체 선택 삭제가 줄 병합으로 가로채지지 않도록 수정.
|
||||
- Shift+Enter·문단 내 hard break 제거. Enter/Shift+Enter 모두 문단 분리. 마크다운 한 줄=한 문단. 소스 모드 Shift+Enter hard break 삽입 제거.
|
||||
- 라이브 모드 ↑↓: 이전·다음 문단(블록) 이동, 같은 열 유지.
|
||||
- 라이브 모드 ←→: 문단 끝 → 다음 블록 맨 앞, 문단 앞 → 이전 블록 맨 끝. ↑↓는 열 유지. Cmd+←→는 블록 내 맨 앞·맨 끝.
|
||||
- 라이브 모드 병합(맨 앞 Backspace): 커서를 합쳐진 경계(이전 줄 본문 끝)에 둠.
|
||||
- 라이브 모드 Enter(문단): 분리 후 blur가 이전 DOM 전체를 다시 저장하던 문제 수정(포커스 중 modelValue 동기화).
|
||||
- contenteditable 줄바꿈(`<br>`·`<div>`) 읽기·커서 오프셋 계산 보강. `좋|아` 분리·복사 버그 수정.
|
||||
- 라이브 모드 Enter(문단): 문장 중간 분리 시 빈 줄 없이 위·아래 두 줄만 생성.
|
||||
- 라이브 모드 Enter(목록): 맨 앞·중간·끝 커서 위치별 분기. 중간은 항목 두 줄로 분리, 끝은 목록 블록 밖 빈 줄 삽입.
|
||||
- 라이브 모드 Enter(인용): 맨 앞·중간 분리 시 `> ` 줄 분리, 끝은 `> ` 이어쓰기 유지.
|
||||
- 라이브 모드 Backspace: 줄 맨 앞(내용 있음)에서 이전 줄 끝으로 병합. 빈 줄·원문 토글 순서 정리.
|
||||
- `lib/markdown-live-edit.js`: `appendTextToMarkdownLine`, `getAppendTextForMerge` 추가.
|
||||
- 패키지 버전 `1.2.9`로 갱신.
|
||||
|
||||
## v1.2.8
|
||||
|
||||
- 라이브 모드: 인용·목록 줄 단위 편집, `> `·`- ` 접두사 중복 표시 제거. 맨 앞 백스페이스로 마크다운 원문(`- 리스트 1` 등) 표시. Cmd+Shift+K 현재 줄 삭제.
|
||||
- 라이브 모드 Cmd+E: contenteditable에서도 소스 모드 전환(capture 단계 리스너). `keydown.stop` 제거.
|
||||
- 라이브 모드 편집 UI: 포커스 시 파란 보더·배경 제거(커서만), 호버는 연한 회색 배경만(포커스 중 호버 없음).
|
||||
- 라이브 모드 Enter(인용·목록): commit+삽입을 한 번에 반영해 2번째 줄 Enter 시 텍스트 복사 방지. `>` 제거 후 Enter 시 일반 문단으로 이탈.
|
||||
- 라이브 모드 Enter(목록): 다음 줄 `- `·`2. ` 자동 삽입 제거. 내용 있으면 빈 줄만 추가, 빈 마커 줄 Enter 시 문단 탈출. 인용은 `> ` 이어쓰기 유지.
|
||||
- 순서 목록: 소스의 숫자(4., 27. 등)를 렌더·commit에 반영(자동 재번호 없음). 목록 마커 `--site-accent` 스타일.
|
||||
- 라이브 모드 목록: 디스크 마커 세로 중앙 정렬(`items-center`). Enter·줄 삭제 후 포커스 유지(retry). 빈 줄 Backspace/Cmd+Shift+K 삭제.
|
||||
- 목록 마커 열 너비 21px·오른쪽 정렬(숫자·디스크 공통, 본문 시작선 정렬).
|
||||
- 빈 줄(spacer) Cmd+Shift+K 삭제. 줄 삭제 후 이전 줄 끝 포커스. 호버 배경 제거.
|
||||
- 라이브 모드 raw: 원문 모드 Enter 시 브라우저 기본 줄바꿈 대신 아래 줄 삽입. 마커(`>`·`-`) 제거 시 일반 문단으로 저장. 목록 raw 시 불릿 숨김.
|
||||
- 라이브 모드 Enter: 커서 뒤 텍스트 잘림 버그 수정(문단 끝에서 이전 줄 내용이 복사되지 않음). 끝 Enter 시 빈 줄 1개만 삽입.
|
||||
- 제목·인용·목록: 블록 안 줄바꿈 대신 아래 새 블록/항목 삽입, 여백 축소.
|
||||
- 라이브 모드 ↑↓·Cmd+←→: 블록 이동·블록 내 처음/끝(열 유지).
|
||||
- 패키지 버전 `1.2.8`로 갱신.
|
||||
|
||||
## v1.2.7
|
||||
|
||||
- 라이브 모드: Enter 시 다음 문단 생성(빈 줄 삽입), Shift+Enter만 같은 문단 내 줄바꿈. 하단 클릭 영역으로 새 문단 추가. 빈 줄도 편집 가능.
|
||||
- 라이브 모드 한글 IME: 조합 중 Enter는 compositionend 후 1회만 분리, blur 중복 commit·연속 split 방지.
|
||||
- 소스 모드 전환 시 textarea 높이·줄 번호 거터 동기화 보강.
|
||||
- 패키지 버전 `1.2.7`로 갱신.
|
||||
|
||||
## v1.2.6
|
||||
|
||||
- 관리자 미리보기 인라인 편집: 문단·제목·인용·목록 항목을 contenteditable로 편집, blur 시 마크다운 본문 반영(`block-content-change`).
|
||||
- `lib/markdown-inline.js`, `ContentMarkdownEditableInline.vue` 추가. HTML↔인라인 마크다운 변환 공유.
|
||||
- 패키지 버전 `1.2.6`으로 갱신.
|
||||
|
||||
## v1.2.5
|
||||
|
||||
- 관리자 미리보기 갤러리: 드래그 중 드롭 대상 셀에 주황 테두리·「여기로 이동」 오버레이 표시, 드래그 중 원본 셀 반투명 처리.
|
||||
- 이미지 **파일명을 캡션으로 사용** 토글: 화면에는 이미지 아래 figcaption으로 표시, 저장은 `` 형식(레거시 `` 호환).
|
||||
- 미리보기 문단 클릭→작성 모드 전환은 제거(요청과 다름). 미리보기 그대로 편집(WYSIWYG)은 후속 작업.
|
||||
- 패키지 버전 `1.2.5`로 갱신.
|
||||
|
||||
## v1.2.4
|
||||
|
||||
- 이미지 캡션: `ProseImage` caption prop, 갤러리 figcaption, 파일명 alt와 별도 표시. useAlt 파일명 비교 URI 디코딩 정규화.
|
||||
- 관리자 미리보기: 갤러리 드래그 순서 변경(`interactive` + `gallery-reorder`).
|
||||
- 패키지 버전 `1.2.4`로 갱신.
|
||||
|
||||
## v1.2.3
|
||||
|
||||
- 관리자 마크다운 에디터: textarea 내부 스크롤·`resize-y` 제거, 본문 높이 자동 확장·`admin-post-form__editor-scroll` 외부 스크롤만 사용, 줄 번호 거터 flex 정렬.
|
||||
- 패키지 버전 `1.2.3`으로 갱신.
|
||||
|
||||
## v1.2.2
|
||||
|
||||
- 이미지 `useAlt` 판별: 대괄호 내용이 URL 파일명과 일치할 때만 파일명 대체 텍스트 모드로 처리(임의 문자열·레거시 표시문구와 분리).
|
||||
- 미리보기 figcaption: 따옴표 캡션 우선, 레거시 ``만 대괄호 문구를 표시용으로 유지.
|
||||
- 패키지 버전 `1.2.2`로 갱신.
|
||||
|
||||
## v1.2.1
|
||||
|
||||
- 관리자 블록 설정 패널: 게시물 설정 사이드바(420px) 오버레이 슬라이드, 이미지·갤러리·임베드 편집.
|
||||
- 이미지: 캡션·대체 텍스트 분리, **파일명을 대체 텍스트로 사용** 토글(기본 끔), `lib/markdown-image.js`·`lib/markdown-block-context.js` 추가.
|
||||
- 블록 패널 캡션·URL 입력 중 포커스 이탈로 패널이 닫히던 문제 수정.
|
||||
- 공개 렌더: 갤러리 라이트박스 Esc·좌우 방향키, 캡션만 figcaption 표시.
|
||||
- 패키지 버전 `1.2.1`로 갱신.
|
||||
|
||||
## v1.2.0
|
||||
|
||||
- 관리자 글 목록: 발행일 기준 정렬(`published_at` 우선, 없으면 `updated_at`), 총·추천·필터 표시 개수, 추천만 필터, 추천 글 별(★) 열.
|
||||
- 관리자 글 슬러그: Post URL 미리보기 즉시 반영·저장 전 안내, 초안은 제목 연동 자동 슬러그(연한 표시), 발행·예약 글은 제목 변경 시 슬러그 고정(중복 409 예방).
|
||||
- 예약·발행 시각: 달력·KST 클릭 영역 `showPicker` 연동.
|
||||
- 이미지·갤러리: 캡션은 사용자 입력 시만 표시(``), 대체 텍스트는 기본 비움·**파일명 사용** 토글 시 URL 파일명을 alt로 저장, 블록 설정 패널을 게시물 설정 사이드바 오버레이로 슬라이드, 갤러리 다중 선택 주황 테두리, 라이트박스 Esc·좌우 방향키.
|
||||
- `lib/markdown-block-context.js`, `AdminEditorBlockPanel.vue` 추가.
|
||||
- 패키지 버전 `1.2.0`으로 갱신.
|
||||
|
||||
## v1.1.19
|
||||
|
||||
- 관리자 글쓰기: 작성/미리보기 토글을 툴바에서 헤더(Update 왼쪽)로 이동, 미리보기 시 툴바 숨김, 미디어 모달 파일명 검색.
|
||||
- 발행 글 Update 시 `publishedAt`이 현재 시각으로 덮이지 않도록 보존 로직 추가.
|
||||
- 관리자 글 설정: 검색엔진 노출 iOS형 토글(`noindex` 반전).
|
||||
- 사이트 설정: 기타·POST 카드 편집/저장/취소 분리, 변경 시에만 저장 활성, POST 설정 토글 UI.
|
||||
- 본문: 인용문 핑크 보더·배경, 인라인 코드 `#252525`, 목록·구분선 여백 정리.
|
||||
- 패키지 버전 `1.1.19`로 갱신.
|
||||
|
||||
## v1.1.18
|
||||
|
||||
- 마크다운 에디터: 이미지 너비 선택 제거, 툴바 `이미지`·`갤러리` 단일 버튼 + 미디어 모달(라이브러리 기본·업로드 탭).
|
||||
- 사이트 설정 `showPostUpdatedAt`(POST 설정 > 수정일 표시), 마이그레이션 `026_site_settings_show_post_updated_at.sql`.
|
||||
- 관리자 글 목록 발행일 시·분 표시, 설정 ON 시 발행 후 수정 줄 보조 표시. 공개 글 상세 동일.
|
||||
- `formatPostDateTime`, `wasPostUpdatedAfterPublish` 추가.
|
||||
- 패키지 버전 `1.1.18`로 갱신.
|
||||
|
||||
## v1.1.17
|
||||
|
||||
- 관리자 글: 제목 없음은 DB/API만 플레이스홀더, 폼·목록은 빈 제목·임시 슬러그는 제목 입력 시 자동 슬러그.
|
||||
- 초안 Publish 모달 기본값 발행, Unpublish/Unschedule 확인 화면 추가, 발행·예약 시각 날짜/시간 분리 입력.
|
||||
- 패키지 버전 `1.1.17`로 갱신.
|
||||
|
||||
## v1.1.16
|
||||
|
||||
- 게시물 `private` 제거: DB `025_posts_status_no_private.sql`, API·폼·목록을 `published`/`draft`+예약 시각만 사용.
|
||||
- 신규 글: 마운트 시 `d`+24hex 임시 슬러그, 빈 제목은 저장 시 `(제목 없음)` 보정.
|
||||
- 발행·예약 글: 사이드바만 바꿔도 자동 저장하지 않음, 툴바는 서버 반영 상태 기준으로 `Update` 유지. 발행 모달 세로 중앙·도구막대 `Preview` 제거.
|
||||
- 패키지 버전 `1.1.16`으로 갱신.
|
||||
|
||||
## v1.1.15
|
||||
|
||||
- 관리자 신규 글: 초안·비공개 시 `POST` 디바운스 자동 저장 후 편집 URL로 교체, 라우트 이탈 직전 미전송 초안 플러시.
|
||||
- 관리자 글 편집: 미저장 이탈 모달은 즉시 발행·예약 미저장에만 적용(초안·비공개는 제외).
|
||||
- 관리자 글 목록: 헤더 한 줄에 필터를 «새 글» 왼쪽에 배치.
|
||||
- 패키지 버전 `1.1.15`로 갱신.
|
||||
|
||||
## v1.1.14
|
||||
|
||||
- 관리자 글쓰기: Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule·색·호버), 초안만 서버 디바운스 자동 저장, 발행·예약은 Update로만 반영, 로컬 자동 저장 복원 UI 제거.
|
||||
- 패키지 버전 `1.1.14`로 갱신.
|
||||
|
||||
## v1.1.13
|
||||
|
||||
- 상단 네비: 하위 1뎁스만 허용(서버 검증·공개 트리 조립·관리자 드래그·이미 하위가 있는 항목의 하위 편입 금지).
|
||||
- `navigation_items.location`에 `recommended` 추가(마이그레이션 `024_navigation_recommended_location.sql`), 관리자 메뉴에 추천 사이트 탭·공개 API `recommended`·우측 사이드 카드 목록.
|
||||
- 외부 링크 파비콘 표시용 `lib/external-favicon-url.js`(Google `s2/favicons` 프록시 URL).
|
||||
- 패키지 버전 `1.1.13`로 갱신.
|
||||
|
||||
## v1.1.12
|
||||
|
||||
- 관리자 상단 메뉴: 드롭 구역을 파란 끝선(형제 앞·뒤)·앰버 링(하위)과 개요 열 캡션으로 구분, 개요 번호를 `2.1`형 계층 표기로 변경·라벨 들여쓰기 확대.
|
||||
- 패키지 버전 `1.1.12`로 갱신.
|
||||
|
||||
## v1.1.11
|
||||
|
||||
- 공개 사이드바 1차 네비: 부모·하위 사이 `mt-2` 간격, 비활성 세로 표시를 `color-mix(in srgb, var(--site-line) 88%, var(--site-panel) 12%)` 톤으로 정리.
|
||||
- 관리자 상단 메뉴: `하위` 버튼 제거, `flattenNavigationEditorWrappers` 단일 테이블+행 위·중·아래 드롭으로 순서·부모 자유 변경, `AdminNavPrimaryBranch.vue` 제거.
|
||||
- 패키지 버전 `1.1.11`로 갱신.
|
||||
|
||||
## v1.1.10
|
||||
|
||||
- 관리자 `/admin/settings`를 Ghost형 전체 화면으로 재구성(좌측 검색·앵커 내비·우측 스크롤 스파이, X·ESC 닫기, 타임존·어나운스·Import/Export·스팸 섹션은 플레이스홀더).
|
||||
- 설정 경로에서 관리자 기본 사이드바를 숨기고 문서 스크롤 잠금(`admin-settings-document`)을 적용.
|
||||
- 관리자 `/admin/settings`에서 상단 헤더를 제거하고 우측 상단 고정 닫기만 두며, 사이드·본문 열을 `max-w-[1120px]` 래퍼로 중앙 정렬·본문 카드 폭은 `max-w-[760px]`로 Ghost에 가깝게 맞춤.
|
||||
|
||||
## v1.1.9
|
||||
|
||||
- 관리자 사이드바 상단에 대시보드(비활성 표시)·사이트 보기(`NUXT_PUBLIC_SITE_URL` 기준 새 창)·콘텐츠 메뉴 구분 여백 추가.
|
||||
- 관리자 글 목록에 상태·태그·최신순/오래된순 필터 추가.
|
||||
- 관리자 글 목록 상태 표시를 배지에서 단순 텍스트 색상 기준으로 정리하고 제목 옆 댓글 수 표시 추가.
|
||||
- 게시물 추천 여부(`is_featured`) 저장 필드와 글쓰기 사이드바 추천 토글 추가.
|
||||
- 홈 Featured 영역을 추천 글이 있을 때만 표시하고, 최신 글의 번개 표시는 실제 추천 글에만 나오도록 수정.
|
||||
- 공개 목록·상세의 댓글 수 표시를 API 댓글 집계값 기준으로 정리.
|
||||
- 공개 헤더의 이미지 로고 주석 코드를 제거하고 사이트 이름 텍스트만 표시하도록 정리.
|
||||
- 왼쪽 사이드바 Authors 영역과 오른쪽 사이드바 About 영역을 비공개 처리.
|
||||
- 패키지 버전 `1.1.9`로 갱신.
|
||||
|
||||
## v1.1.8
|
||||
|
||||
- 사이트 로고·파비콘 고유 파일명 접미사를 년월+랜덤 문자열 형식으로 간소화.
|
||||
- 관리자 태그 관리 화면의 `태그 추가` 버튼을 일반 태그 섹션 헤더 오른쪽으로 이동.
|
||||
- 메인 태그 `정렬 저장` 버튼을 제거하고 드래그 드롭 직후 자동 저장되도록 수정.
|
||||
- 메인 태그 순서 자동 저장 중 추가 드래그를 막고 저장 상태를 표시하도록 정리.
|
||||
- 패키지 버전 `1.1.8`로 갱신.
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정.
|
||||
- 사이트 파비콘도 로고와 같은 고유 접미사 파일명으로 생성해 브라우저 캐시로 이전 이미지가 남는 문제를 완화.
|
||||
- 미디어 라이브러리 사용 현황에 사이트 설정 로고·파비콘 참조를 포함하고, 현재 사용 중인 시스템 이미지는 파일명 변경·삭제가 잠기도록 수정.
|
||||
- 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 todo 정리.
|
||||
- 패키지 버전 `1.1.7`로 갱신.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그를 검색 전에도 전체 목록으로 확인할 수 있도록 수정.
|
||||
- 일반 태그 목록을 배지형 flex-wrap UI로 표시하고, 최근 사용/수정 태그가 앞에 오도록 정렬.
|
||||
- 일반 태그 배지 목록에 최근 사용순·많이 사용순·이름순 정렬 전환을 추가.
|
||||
- 일반 태그 배지에서 이름·슬러그 로컬 필터, 메인 태그 전환, 삭제를 바로 수행하도록 정리.
|
||||
- 관리자 태그 추가 화면에서 새 태그는 일반 태그로 유지하되, 생성 후 태그 관리 화면에서 누락처럼 보이지 않게 정리.
|
||||
- 패키지 버전 `1.1.6`으로 갱신.
|
||||
|
||||
## v1.1.5
|
||||
|
||||
- 운영 빌드에서 런타임 업로드 파일을 `/app/public/uploads` 볼륨에서 직접 제공하는 `/uploads/**` 서버 라우트 추가.
|
||||
- Docker 운영 이미지의 `.output/public` 빌드 시점 스냅샷에 의존하지 않고 새로 업로드한 로고·게시물 이미지·회원 썸네일이 즉시 표시되도록 수정.
|
||||
- 패키지 버전 `1.1.5`로 갱신.
|
||||
|
||||
## v1.1.4
|
||||
|
||||
- 관리자 멤버 썸네일 업로드가 게시물용 `/uploads/posts`가 아니라 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||
- 회원 썸네일 업로드 검증·WebP 변환·1:1 크롭 로직을 공통 유틸로 분리.
|
||||
- 관리자 멤버 편집 전용 썸네일 업로드 API와 새 멤버 생성 전 썸네일 사전 업로드 API 추가.
|
||||
- 관리자 회원 기본 정보 저장에서 기존 회원 전용 썸네일 URL이 교체·제거되면 `media_metadata` 연결을 분리하도록 정리.
|
||||
- 태그 목록 카드 그리드에 사용자 수정 `px-6` 반영.
|
||||
- 패키지 버전 `1.1.4`로 갱신.
|
||||
|
||||
## v1.1.3
|
||||
|
||||
- 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 행 호버를 `site-sidebar-nav-row`로 분리하고, 라이트 테마에서 배경 `#F7F4EF`로 완화. 다크 테마는 기존 `color-mix` 패널 호버 유지.
|
||||
- 패키지 버전 `1.1.3`으로 갱신.
|
||||
|
||||
## v1.1.2
|
||||
|
||||
- 태그가 없는 게시물에 기본값으로 보이던 `POST` 표기 제거: 공개 상세·홈 Latest·게시물 목록 카드에서 태그가 있을 때만 배지·메타에 표시.
|
||||
- 패키지 버전 `1.1.2`로 갱신.
|
||||
|
||||
## v1.1.1
|
||||
|
||||
- 공개 본문 `ContentMarkdownRenderer` 문단에서 `leading-7`을 제거하고 `text-base`(16px)만 적용.
|
||||
- 패키지 버전 `1.1.1`로 갱신.
|
||||
|
||||
## v1.1.0
|
||||
|
||||
- 관리자 글 작성 폼 제목 입력 타이포를 `text-5xl`에서 `text-3xl`로 조정.
|
||||
- 공개 본문 `ContentMarkdownRenderer` 문단을 `text-base leading-7` 기준으로 조정(기존 `text-[15px] leading-4` 대비 크기·행간 정리).
|
||||
- 공개 본문 `ContentMarkdownRenderer` 문단을 `text-base`·`leading-7` 기준으로 조정(기존 `text-[15px] leading-4` 대비 크기·행간 정리).
|
||||
- `ProseHeading`에서 제목 블록 상단 `mt-12` 제거로 제목·본문 간 세로 리듬 정리.
|
||||
- 패키지 버전 `1.1.0`으로 갱신.
|
||||
|
||||
|
||||
45
error.vue
Normal file
45
error.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
const error = useError()
|
||||
|
||||
const statusCode = computed(() => Number(error.value?.statusCode || 500))
|
||||
const isNotFound = computed(() => statusCode.value === 404)
|
||||
const pageTitle = computed(() => (isNotFound.value ? '페이지를 찾을 수 없습니다' : '오류가 발생했습니다'))
|
||||
const pageDescription = computed(() => (
|
||||
isNotFound.value
|
||||
? '요청한 주소가 없거나 공개되지 않은 페이지입니다.'
|
||||
: '잠시 후 다시 시도해 주세요.'
|
||||
))
|
||||
|
||||
useHead(() => ({
|
||||
title: pageTitle.value
|
||||
}))
|
||||
|
||||
/**
|
||||
* 오류 상태를 정리하고 홈으로 이동한다.
|
||||
* @returns {Promise<void>} 이동 처리
|
||||
*/
|
||||
const goHome = () => clearError({ redirect: '/' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="error-page min-h-screen bg-[var(--site-bg)] px-5 py-12 text-[var(--site-text)]">
|
||||
<section class="error-page__panel mx-auto flex min-h-[calc(100vh-6rem)] max-w-3xl flex-col items-start justify-center">
|
||||
<p class="error-page__code text-sm font-semibold uppercase tracking-[0.12em] text-[var(--site-muted)]">
|
||||
{{ statusCode }}
|
||||
</p>
|
||||
<h1 class="error-page__title mt-4 text-4xl font-bold tracking-tight md:text-6xl">
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<p class="error-page__description mt-5 max-w-xl text-base leading-7 text-[var(--site-muted)] md:text-lg">
|
||||
{{ pageDescription }}
|
||||
</p>
|
||||
<button
|
||||
class="error-page__home-button mt-8 inline-flex h-11 items-center justify-center rounded-md bg-[var(--site-text)] px-5 text-sm font-semibold text-[var(--site-bg)] transition hover:opacity-85"
|
||||
type="button"
|
||||
@click="goHome"
|
||||
>
|
||||
홈으로 이동
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,12 +1,28 @@
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
|
||||
/**
|
||||
* 공개 블로그 베이스 URL (후행 슬래시 제거, 새 창 링크용)
|
||||
* @returns {string} 절대 URL
|
||||
*/
|
||||
const publicBlogBaseUrl = computed(() => {
|
||||
const raw = String(runtimeConfig.public?.siteUrl || '').trim()
|
||||
return raw.replace(/\/+$/, '') || 'https://sori.studio'
|
||||
})
|
||||
|
||||
const isPostEditorRoute = computed(() => route.path === '/admin/posts/new'
|
||||
|| (route.path.startsWith('/admin/posts/') && route.path !== '/admin/posts/preview'))
|
||||
const isPageEditorRoute = computed(() => route.path === '/admin/pages/new'
|
||||
|| (route.path.startsWith('/admin/pages/') && route.path !== '/admin/pages'))
|
||||
|
||||
const editorDocumentClass = 'admin-post-editor-document'
|
||||
const settingsDocumentClass = 'admin-settings-document'
|
||||
const adminUserMenuOpen = ref(false)
|
||||
|
||||
const isAdminSettingsRoute = computed(() => route.path === '/admin/settings'
|
||||
|| route.path.startsWith('/admin/settings/'))
|
||||
|
||||
const { data: adminMember } = await useFetch('/api/auth/me', {
|
||||
default: () => ({
|
||||
username: '',
|
||||
@@ -25,6 +41,7 @@ const adminDisplayEmail = computed(() => adminMember.value?.email || '')
|
||||
const adminAvatarUrl = computed(() => adminMember.value?.avatarUrl || '')
|
||||
const adminAvatarInitial = computed(() => adminDisplayName.value.slice(0, 1).toUpperCase() || 'A')
|
||||
const adminProfilePath = computed(() => adminMember.value?.id ? `/admin/members/${adminMember.value.id}` : '/admin/members')
|
||||
const isAdminDashboardRoute = computed(() => route.path === '/admin')
|
||||
|
||||
/**
|
||||
* 관리자 내비게이션 활성 경로 확인
|
||||
@@ -67,19 +84,23 @@ const onAdminDocumentPointerDown = (event) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 글쓰기 전체 화면 문서 스크롤 잠금 적용
|
||||
* 글쓰기·설정 전체 화면 문서 스크롤 잠금 적용
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncPostEditorDocumentClass = () => {
|
||||
const syncAdminShellDocumentClass = () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
||||
document.body.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
||||
const editorOn = isPostEditorRoute.value || isPageEditorRoute.value
|
||||
const settingsOn = isAdminSettingsRoute.value && !editorOn
|
||||
document.documentElement.classList.toggle(editorDocumentClass, editorOn)
|
||||
document.body.classList.toggle(editorDocumentClass, editorOn)
|
||||
document.documentElement.classList.toggle(settingsDocumentClass, settingsOn)
|
||||
document.body.classList.toggle(settingsDocumentClass, settingsOn)
|
||||
}
|
||||
|
||||
watchEffect(syncPostEditorDocumentClass)
|
||||
watchEffect(syncAdminShellDocumentClass)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onAdminDocumentPointerDown)
|
||||
@@ -92,6 +113,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
document.documentElement.classList.remove(editorDocumentClass)
|
||||
document.body.classList.remove(editorDocumentClass)
|
||||
document.documentElement.classList.remove(settingsDocumentClass)
|
||||
document.body.classList.remove(settingsDocumentClass)
|
||||
document.removeEventListener('pointerdown', onAdminDocumentPointerDown)
|
||||
})
|
||||
|
||||
@@ -111,10 +134,13 @@ const logoutAdmin = async () => {
|
||||
<template>
|
||||
<div
|
||||
class="admin-layout bg-[#f7f8fa] text-ink"
|
||||
:class="isPostEditorRoute ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'"
|
||||
:class="[
|
||||
(isPostEditorRoute || isPageEditorRoute || isAdminSettingsRoute) ? 'h-screen overflow-hidden bg-white' : 'min-h-screen',
|
||||
{ 'admin-layout--light-controls': !isPostEditorRoute && !isPageEditorRoute }
|
||||
]"
|
||||
>
|
||||
<aside
|
||||
v-if="!isPostEditorRoute"
|
||||
v-if="!isPostEditorRoute && !isPageEditorRoute && !isAdminSettingsRoute"
|
||||
class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-80 flex-col border-r border-[#e6e8eb] bg-[#f7f8fa] px-5 py-6 text-[#15171a] lg:flex"
|
||||
>
|
||||
<NuxtLink class="admin-layout__brand flex items-center gap-3 px-2 text-[0.95rem] font-semibold tracking-[-0.01em]" to="/admin">
|
||||
@@ -124,6 +150,29 @@ const logoutAdmin = async () => {
|
||||
<span>sori.studio</span>
|
||||
</NuxtLink>
|
||||
<nav class="admin-layout__nav mt-10 grid gap-1.5 text-sm font-medium text-[#5d6673]">
|
||||
<NuxtLink
|
||||
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
:class="isAdminDashboardRoute ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||
to="/admin"
|
||||
>
|
||||
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0 opacity-60" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M22.272 23.247a.981.981 0 00.978-.978V9.747a1.181 1.181 0 00-.377-.8L12 .747l-10.873 8.2a1.181 1.181 0 00-.377.8v12.522a.981.981 0 00.978.978z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span>대시보드</span>
|
||||
</NuxtLink>
|
||||
<a
|
||||
class="admin-layout__nav-link admin-layout__nav-link--external flex items-center gap-3 rounded-md px-3 py-2 text-[#5d6673] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
:href="publicBlogBaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="1.5" y="1.497" width="21" height="21" rx="1.5" ry="1.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="M1.5 7.497h21m-13.5 15v-15" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span>사이트 보기</span>
|
||||
</a>
|
||||
<div class="admin-layout__nav-divider h-6 shrink-0" aria-hidden="true" />
|
||||
<div
|
||||
class="admin-layout__nav-item group flex items-center rounded-md transition-colors"
|
||||
:class="isAdminNavActive('/admin/posts') ? 'bg-[#e9ecef] text-[#15171a]' : 'hover:bg-[#eceff2] hover:text-[#15171a]'"
|
||||
@@ -260,10 +309,12 @@ const logoutAdmin = async () => {
|
||||
</div>
|
||||
</aside>
|
||||
<main
|
||||
class="admin-layout__main bg-paper"
|
||||
class="admin-layout__main"
|
||||
:class="[
|
||||
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen px-8 py-8 xl:px-12 xl:py-10',
|
||||
{ 'lg:ml-80': !isPostEditorRoute }
|
||||
isPostEditorRoute || isPageEditorRoute || isAdminSettingsRoute
|
||||
? 'h-screen overflow-hidden bg-white'
|
||||
: 'min-h-screen bg-paper px-8 py-8 xl:px-12 xl:py-10',
|
||||
{ 'lg:ml-80': !isPostEditorRoute && !isPageEditorRoute && !isAdminSettingsRoute }
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -30,7 +30,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="site-shell public-layout">
|
||||
<SiteHeader class="shrink-0" />
|
||||
<SiteTopChrome>
|
||||
<SiteHeader />
|
||||
</SiteTopChrome>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
@@ -39,13 +41,14 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div
|
||||
v-show="menuOpen"
|
||||
class="public-layout__nav-backdrop fixed inset-x-0 top-[57px] bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
|
||||
class="public-layout__nav-backdrop fixed inset-x-0 bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
|
||||
style="top: var(--site-top-chrome-height, 57px)"
|
||||
aria-hidden="true"
|
||||
@click="closeMenu"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
class="public-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
|
||||
class="public-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
|
||||
:class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
|
||||
>
|
||||
<main
|
||||
|
||||
@@ -30,7 +30,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="site-shell post-layout">
|
||||
<SiteHeader class="shrink-0" />
|
||||
<SiteTopChrome>
|
||||
<SiteHeader />
|
||||
</SiteTopChrome>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
@@ -39,13 +41,14 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div
|
||||
v-show="menuOpen"
|
||||
class="post-layout__nav-backdrop fixed inset-x-0 top-[57px] bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
|
||||
class="post-layout__nav-backdrop fixed inset-x-0 bottom-0 z-40 bg-black/35 backdrop-blur-[2px] lg:hidden"
|
||||
style="top: var(--site-top-chrome-height, 57px)"
|
||||
aria-hidden="true"
|
||||
@click="closeMenu"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
class="post-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
|
||||
class="post-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
|
||||
:class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
|
||||
>
|
||||
<main
|
||||
|
||||
41
lib/admin-post-title.js
Normal file
41
lib/admin-post-title.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/** @type {string} DB/API에만 쓰는 제목 없음 표시(폼·목록에는 빈 문자열로 노출) */
|
||||
export const ADMIN_POST_PLACEHOLDER_TITLE = '(제목 없음)'
|
||||
|
||||
/**
|
||||
* 제목 없음 플레이스홀더 여부
|
||||
* @param {string | null | undefined} title - 제목
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isAdminPostPlaceholderTitle = (title) => {
|
||||
const t = String(title ?? '').trim()
|
||||
return !t || t === ADMIN_POST_PLACEHOLDER_TITLE
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 폼·목록에 표시할 제목(플레이스홀더는 빈 문자열)
|
||||
* @param {string | null | undefined} title - DB/API 제목
|
||||
* @returns {string}
|
||||
*/
|
||||
export const toAdminPostFormTitle = (title) => {
|
||||
if (isAdminPostPlaceholderTitle(title)) {
|
||||
return ''
|
||||
}
|
||||
return String(title).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* API 저장용 제목(비어 있으면 플레이스홀더)
|
||||
* @param {string | null | undefined} title - 폼 제목
|
||||
* @returns {string}
|
||||
*/
|
||||
export const toAdminPostStoredTitle = (title) => {
|
||||
const t = String(title ?? '').trim()
|
||||
return t.length ? t : ADMIN_POST_PLACEHOLDER_TITLE
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 슬러그 여부(d + 24~25자리 hex)
|
||||
* @param {string | null | undefined} slug - 슬러그
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isAdminPostDraftPlaceholderSlug = (slug) => /^d[0-9a-f]{24,25}$/i.test(String(slug ?? '').trim())
|
||||
149
lib/analytics-shared.js
Normal file
149
lib/analytics-shared.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/** @type {RegExp} 추적 제외 경로 */
|
||||
const EXCLUDED_PATH_PATTERN = /^\/(admin|signin|signup|forgot-password|settings)(\/|$)/
|
||||
|
||||
/** @type {RegExp} 봇 User-Agent 패턴 */
|
||||
const BOT_USER_AGENT_PATTERN = /bot|crawl|spider|slurp|preview|headless|lighthouse|bytespider|facebookexternalhit/i
|
||||
|
||||
/**
|
||||
* 오늘 날짜(UTC)를 YYYY-MM-DD로 반환한다.
|
||||
* @returns {string} 날짜 문자열
|
||||
*/
|
||||
export const getAnalyticsDayKey = () => {
|
||||
const now = new Date()
|
||||
return now.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준일에서 지정 일수만큼 이전 날짜를 YYYY-MM-DD로 반환한다.
|
||||
* @param {string} dayKey - 기준일(YYYY-MM-DD)
|
||||
* @param {number} daysBefore - 며칠 전(0이면 같은 날)
|
||||
* @returns {string} 시작일
|
||||
*/
|
||||
export const getAnalyticsDayBefore = (dayKey, daysBefore) => {
|
||||
const offset = Math.max(Number(daysBefore) || 0, 0)
|
||||
const base = new Date(`${dayKey}T00:00:00.000Z`)
|
||||
base.setUTCDate(base.getUTCDate() - offset)
|
||||
return base.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 추적 대상 경로인지 확인한다.
|
||||
* @param {string} path - 요청 경로
|
||||
* @returns {boolean} 추적 가능 여부
|
||||
*/
|
||||
export const isTrackableAnalyticsPath = (path) => {
|
||||
const normalized = (path || '').trim()
|
||||
if (!normalized.startsWith('/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (EXCLUDED_PATH_PATTERN.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 User-Agent 여부
|
||||
* @param {string} userAgent - User-Agent
|
||||
* @returns {boolean} 봇 여부
|
||||
*/
|
||||
export const isBotUserAgent = (userAgent) => {
|
||||
const value = (userAgent || '').trim()
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return BOT_USER_AGENT_PATTERN.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 slug 정규화
|
||||
* @param {string} slug - slug
|
||||
* @returns {string} 정규화된 slug
|
||||
*/
|
||||
export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim()
|
||||
|
||||
/**
|
||||
* 페이지 slug 정규화
|
||||
* @param {string} slug - slug
|
||||
* @returns {string} 정규화된 slug
|
||||
*/
|
||||
export const normalizePageSlugForAnalytics = (slug) => (slug || '').trim()
|
||||
|
||||
/** @type {number} heartbeat 체류시간 상한(초) */
|
||||
export const ANALYTICS_MAX_DURATION_SECONDS = 1800
|
||||
|
||||
/** @type {number} engaged_views 집계 최소 체류(초) */
|
||||
export const ANALYTICS_ENGAGED_MIN_SECONDS = 10
|
||||
|
||||
/** @type {number} 현재 접속자 판정 TTL(초) */
|
||||
export const ANALYTICS_ACTIVE_SESSION_TTL_SECONDS = 90
|
||||
|
||||
/** @type {number} 관리자 차트 최대 조회 기간(일) */
|
||||
export const ANALYTICS_CHART_MAX_DAYS = 365
|
||||
|
||||
/** @type {number} 일별 방문자 해시 보관 기간(일) */
|
||||
export const ANALYTICS_VISITOR_HASH_RETENTION_DAYS = 32
|
||||
|
||||
/** @type {number} 통계 정리 최소 실행 간격(ms) */
|
||||
export const ANALYTICS_RETENTION_PURGE_INTERVAL_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
/** @type {number[]} 스크롤 구간 임계값 */
|
||||
export const ANALYTICS_SCROLL_THRESHOLDS = [0.25, 0.5, 0.75, 1]
|
||||
|
||||
/**
|
||||
* 체류시간(초)을 상한 내로 보정한다.
|
||||
* @param {number} seconds - 체류시간
|
||||
* @returns {number} 보정된 초
|
||||
*/
|
||||
export const clampAnalyticsDurationSeconds = (seconds) => {
|
||||
const value = Number(seconds)
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.min(Math.floor(value), ANALYTICS_MAX_DURATION_SECONDS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크롤 비율을 0~1로 보정한다.
|
||||
* @param {number} ratio - 스크롤 비율
|
||||
* @returns {number} 보정된 비율
|
||||
*/
|
||||
export const clampAnalyticsScrollRatio = (ratio) => {
|
||||
const value = Number(ratio)
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.min(value, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로 통과한 스크롤 구간 컬럼명 목록을 반환한다.
|
||||
* @param {number} previousRatio - 이전 최대 스크롤
|
||||
* @param {number} nextRatio - 갱신된 최대 스크롤
|
||||
* @returns {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} 신규 구간
|
||||
*/
|
||||
export const getNewScrollBucketColumns = (previousRatio, nextRatio) => {
|
||||
const previous = clampAnalyticsScrollRatio(previousRatio)
|
||||
const next = clampAnalyticsScrollRatio(nextRatio)
|
||||
const columns = []
|
||||
|
||||
if (previous < 0.25 && next >= 0.25) {
|
||||
columns.push('scroll_25')
|
||||
}
|
||||
if (previous < 0.5 && next >= 0.5) {
|
||||
columns.push('scroll_50')
|
||||
}
|
||||
if (previous < 0.75 && next >= 0.75) {
|
||||
columns.push('scroll_75')
|
||||
}
|
||||
if (previous < 1 && next >= 1) {
|
||||
columns.push('scroll_100')
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
152
lib/analytics-traffic.js
Normal file
152
lib/analytics-traffic.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/** @type {number} 통계 키워드 최대 길이 */
|
||||
const MAX_KEYWORD_LENGTH = 80
|
||||
|
||||
/** @type {Array<{ name: string, hostPattern: RegExp, keywordParams: string[] }>} 검색 유입 규칙 */
|
||||
const SEARCH_SOURCE_RULES = [
|
||||
{ name: '구글', hostPattern: /(^|\.)google\./i, keywordParams: ['q'] },
|
||||
{ name: '네이버', hostPattern: /(^|\.)naver\.com$/i, keywordParams: ['query', 'q'] },
|
||||
{ name: '다음', hostPattern: /(^|\.)daum\.net$|(^|\.)kakao\.com$/i, keywordParams: ['q', 'query'] },
|
||||
{ name: '빙', hostPattern: /(^|\.)bing\.com$/i, keywordParams: ['q'] },
|
||||
{ name: '줌', hostPattern: /(^|\.)zum\.com$/i, keywordParams: ['query', 'q'] }
|
||||
]
|
||||
|
||||
/** @type {Array<{ name: string, hostPattern: RegExp }>} SNS 유입 규칙 */
|
||||
const SOCIAL_SOURCE_RULES = [
|
||||
{ name: '카카오톡', hostPattern: /(^|\.)kakao\.com$|(^|\.)kakaocdn\.net$/i },
|
||||
{ name: '페이스북', hostPattern: /(^|\.)facebook\.com$|(^|\.)fb\.com$|(^|\.)fb\.me$/i },
|
||||
{ name: '인스타그램', hostPattern: /(^|\.)instagram\.com$/i },
|
||||
{ name: '트위터', hostPattern: /(^|\.)twitter\.com$|(^|\.)x\.com$|(^|\.)t\.co$/i },
|
||||
{ name: '유튜브', hostPattern: /(^|\.)youtube\.com$|(^|\.)youtu\.be$/i }
|
||||
]
|
||||
|
||||
/**
|
||||
* 문자열 값을 안전한 통계 라벨로 정리한다.
|
||||
* @param {string} value - 원본 문자열
|
||||
* @param {number} maxLength - 최대 길이
|
||||
* @returns {string} 정리된 문자열
|
||||
*/
|
||||
const normalizeAnalyticsText = (value, maxLength = 120) => {
|
||||
return String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 문자열을 URL 객체로 변환한다.
|
||||
* @param {string} value - URL 문자열
|
||||
* @returns {URL | null} URL 객체
|
||||
*/
|
||||
const parseAnalyticsUrl = (value) => {
|
||||
const rawValue = String(value || '').trim()
|
||||
if (!rawValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(rawValue)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 유입 키워드를 추출한다.
|
||||
* @param {URL} url - referrer URL
|
||||
* @param {string[]} params - 키워드 후보 파라미터
|
||||
* @returns {string} 키워드
|
||||
*/
|
||||
const extractSearchKeyword = (url, params) => {
|
||||
const keywordParams = [...params, 'keyword', 'search']
|
||||
for (const param of keywordParams) {
|
||||
const value = normalizeAnalyticsText(url.searchParams.get(param), MAX_KEYWORD_LENGTH)
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 URL을 검색·SNS·직접·기타로 분류한다.
|
||||
* @param {{ referrer?: string, currentUrl?: string }} input - 분류 입력
|
||||
* @returns {{ sourceGroup: string, sourceName: string, keyword: string }} 유입 분류
|
||||
*/
|
||||
export const classifyAnalyticsTrafficSource = (input = {}) => {
|
||||
const referrerUrl = parseAnalyticsUrl(input.referrer)
|
||||
const currentUrl = parseAnalyticsUrl(input.currentUrl)
|
||||
|
||||
if (!referrerUrl) {
|
||||
return {
|
||||
sourceGroup: 'direct',
|
||||
sourceName: '직접 유입',
|
||||
keyword: ''
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUrl && referrerUrl.hostname === currentUrl.hostname) {
|
||||
return {
|
||||
sourceGroup: 'direct',
|
||||
sourceName: '직접 유입',
|
||||
keyword: ''
|
||||
}
|
||||
}
|
||||
|
||||
const host = referrerUrl.hostname.replace(/^www\./i, '')
|
||||
const searchRule = SEARCH_SOURCE_RULES.find((rule) => rule.hostPattern.test(host))
|
||||
|
||||
if (searchRule) {
|
||||
return {
|
||||
sourceGroup: 'search',
|
||||
sourceName: searchRule.name,
|
||||
keyword: extractSearchKeyword(referrerUrl, searchRule.keywordParams)
|
||||
}
|
||||
}
|
||||
|
||||
const socialRule = SOCIAL_SOURCE_RULES.find((rule) => rule.hostPattern.test(host))
|
||||
|
||||
if (socialRule) {
|
||||
return {
|
||||
sourceGroup: 'sns',
|
||||
sourceName: socialRule.name,
|
||||
keyword: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceGroup: 'other',
|
||||
sourceName: '기타 유입',
|
||||
keyword: ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Agent에서 디바이스와 OS를 분류한다.
|
||||
* @param {string} userAgent - User-Agent
|
||||
* @returns {{ deviceType: string, osName: string }} 디바이스 분류
|
||||
*/
|
||||
export const classifyAnalyticsDevice = (userAgent) => {
|
||||
const value = String(userAgent || '')
|
||||
const lowerValue = value.toLowerCase()
|
||||
const isMobile = /mobile|iphone|ipod|android.*mobile|windows phone/i.test(value)
|
||||
const isTablet = /ipad|tablet|android(?!.*mobile)/i.test(value)
|
||||
let osName = '기타'
|
||||
|
||||
if (/iphone|ipad|ipod/i.test(value)) {
|
||||
osName = 'iOS'
|
||||
} else if (/android/i.test(value)) {
|
||||
osName = 'Android'
|
||||
} else if (/windows/i.test(value)) {
|
||||
osName = 'Windows'
|
||||
} else if (/mac os|macintosh|mac_powerpc/i.test(lowerValue)) {
|
||||
osName = 'macOS'
|
||||
} else if (/linux/i.test(value)) {
|
||||
osName = 'Linux'
|
||||
}
|
||||
|
||||
return {
|
||||
deviceType: isMobile || isTablet ? '모바일' : 'PC',
|
||||
osName
|
||||
}
|
||||
}
|
||||
40
lib/analytics.js
Normal file
40
lib/analytics.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export {
|
||||
ANALYTICS_ACTIVE_SESSION_TTL_SECONDS,
|
||||
ANALYTICS_CHART_MAX_DAYS,
|
||||
ANALYTICS_ENGAGED_MIN_SECONDS,
|
||||
ANALYTICS_MAX_DURATION_SECONDS,
|
||||
ANALYTICS_RETENTION_PURGE_INTERVAL_MS,
|
||||
ANALYTICS_SCROLL_THRESHOLDS,
|
||||
ANALYTICS_VISITOR_HASH_RETENTION_DAYS,
|
||||
clampAnalyticsDurationSeconds,
|
||||
clampAnalyticsScrollRatio,
|
||||
getAnalyticsDayBefore,
|
||||
getAnalyticsDayKey,
|
||||
getNewScrollBucketColumns,
|
||||
isBotUserAgent,
|
||||
isTrackableAnalyticsPath,
|
||||
normalizePageSlugForAnalytics,
|
||||
normalizePostSlugForAnalytics
|
||||
} from './analytics-shared.js'
|
||||
|
||||
/**
|
||||
* 일 단위 익명 방문자 해시를 생성한다. 원문 IP·UA는 저장하지 않는다.
|
||||
* @param {{ day: string, ip: string, userAgent: string, secret: string }} input - 해시 입력
|
||||
* @returns {string} visitor hash
|
||||
*/
|
||||
export const createDailyVisitorHash = ({ day, ip, userAgent, secret }) => {
|
||||
const payload = `${day}|${ip}|${userAgent}|${secret}`
|
||||
return createHash('sha256').update(payload).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 세션 해시를 생성한다.
|
||||
* @param {{ clientSessionId: string, visitorHash: string, secret: string }} input - 해시 입력
|
||||
* @returns {string} session hash
|
||||
*/
|
||||
export const createRealtimeSessionHash = ({ clientSessionId, visitorHash, secret }) => {
|
||||
const payload = `${clientSessionId}|${visitorHash}|${secret}`
|
||||
return createHash('sha256').update(payload).digest('hex')
|
||||
}
|
||||
210
lib/announcement-bar.js
Normal file
210
lib/announcement-bar.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 어나운스 바 배경색 프리셋
|
||||
* @type {ReadonlyArray<{ id: string, label: string, value: string, textColor: string }>}
|
||||
*/
|
||||
export const ANNOUNCEMENT_BACKGROUND_PRESETS = [
|
||||
{ id: 'black', label: '검정', value: '#15171a', textColor: '#ffffff' },
|
||||
{ id: 'white', label: '흰색', value: '#ffffff', textColor: '#15171a' },
|
||||
{ id: 'accent', label: '브랜드', value: '#ff4f2e', textColor: '#ffffff' }
|
||||
]
|
||||
|
||||
/** @type {string} 기본 어나운스 바 배경색 */
|
||||
export const DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR = '#15171a'
|
||||
|
||||
/** @type {string} 기본 어나운스 바 정렬 */
|
||||
export const DEFAULT_ANNOUNCEMENT_ALIGNMENT = 'center'
|
||||
|
||||
/**
|
||||
* 어나운스 바 정렬 옵션
|
||||
* @type {ReadonlyArray<{ value: string, label: string }>}
|
||||
*/
|
||||
export const ANNOUNCEMENT_ALIGNMENT_OPTIONS = [
|
||||
{ value: 'center', label: '중앙' },
|
||||
{ value: 'left', label: '왼쪽' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 어나운스 바 배경색 형식이 올바른지 확인한다.
|
||||
* @param {unknown} value - hex 색상
|
||||
* @returns {boolean} 유효 여부
|
||||
*/
|
||||
export const isValidAnnouncementBackgroundColor = (value) => /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || '').trim())
|
||||
|
||||
/**
|
||||
* 어나운스 바 배경색을 6자리 hex 값으로 정규화한다.
|
||||
* @param {unknown} value - 입력 색상
|
||||
* @returns {string} 정규화된 색상
|
||||
*/
|
||||
export const normalizeAnnouncementBackgroundColor = (value) => {
|
||||
const color = String(value || '').trim().toLowerCase()
|
||||
|
||||
if (!isValidAnnouncementBackgroundColor(color)) {
|
||||
return DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR
|
||||
}
|
||||
|
||||
if (color.length === 4) {
|
||||
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
/**
|
||||
* 어나운스 바 정렬 값을 정리한다.
|
||||
* @param {unknown} value - 입력 정렬
|
||||
* @returns {string} 정리된 정렬
|
||||
*/
|
||||
export const normalizeAnnouncementAlignment = (value) => {
|
||||
const alignment = String(value || '').trim().toLowerCase()
|
||||
return ANNOUNCEMENT_ALIGNMENT_OPTIONS.some((option) => option.value === alignment)
|
||||
? alignment
|
||||
: DEFAULT_ANNOUNCEMENT_ALIGNMENT
|
||||
}
|
||||
|
||||
/**
|
||||
* 어나운스 바 배경색에 맞는 전경색을 반환한다.
|
||||
* @param {string} backgroundColor - hex 배경색
|
||||
* @returns {string} 전경 hex 색상
|
||||
*/
|
||||
export const getAnnouncementBarTextColor = (backgroundColor) => {
|
||||
const normalized = normalizeAnnouncementBackgroundColor(backgroundColor)
|
||||
const preset = ANNOUNCEMENT_BACKGROUND_PRESETS.find((item) => item.value.toLowerCase() === normalized)
|
||||
if (preset) {
|
||||
return preset.textColor
|
||||
}
|
||||
|
||||
const hex = normalized.slice(1)
|
||||
const red = parseInt(hex.slice(0, 2), 16)
|
||||
const green = parseInt(hex.slice(2, 4), 16)
|
||||
const blue = parseInt(hex.slice(4, 6), 16)
|
||||
const luminance = (red * 299 + green * 587 + blue * 114) / 1000
|
||||
|
||||
return luminance > 150 ? '#15171a' : '#ffffff'
|
||||
}
|
||||
|
||||
/**
|
||||
* 어나운스 링크를 정리한다. 빈 값은 링크 미사용.
|
||||
* @param {string} url - 입력 URL
|
||||
* @returns {string} 정리된 URL 또는 빈 문자열
|
||||
*/
|
||||
export const normalizeAnnouncementUrl = (url) => {
|
||||
const trimmed = (url || '').trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.toString()
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/** @type {string} 어나운스 바 7일 숨김 localStorage 키 */
|
||||
export const ANNOUNCEMENT_DISMISS_STORAGE_KEY = 'SITE_ANNOUNCEMENT_DISMISS'
|
||||
|
||||
/** @type {string} 어나운스 바 이번 방문(세션) 숨김 sessionStorage 키 */
|
||||
export const ANNOUNCEMENT_SESSION_DISMISS_KEY = 'SITE_ANNOUNCEMENT_SESSION_DISMISS'
|
||||
|
||||
/** @type {number} 기본 숨김 일수 */
|
||||
export const ANNOUNCEMENT_SNOOZE_DAYS = 7
|
||||
|
||||
/**
|
||||
* 어나운스 바 닫기 식별 키(설정 저장 시각)
|
||||
* @param {{ updatedAt?: string | null }} settings - 사이트 설정
|
||||
* @returns {string} 식별 키
|
||||
*/
|
||||
export const getAnnouncementDismissKey = (settings) => settings?.updatedAt || ''
|
||||
|
||||
/**
|
||||
* 어나운스 바가 숨김 상태인지 확인한다.
|
||||
* @param {{ updatedAt?: string | null }} settings - 사이트 설정
|
||||
* @returns {boolean} 숨김 여부
|
||||
*/
|
||||
export const isAnnouncementDismissed = (settings) => {
|
||||
if (!import.meta.client) {
|
||||
return false
|
||||
}
|
||||
|
||||
const key = getAnnouncementDismissKey(settings)
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionDismissed = sessionStorage.getItem(ANNOUNCEMENT_SESSION_DISMISS_KEY)
|
||||
if (sessionDismissed === key) {
|
||||
return true
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(ANNOUNCEMENT_DISMISS_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed?.key !== key) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof parsed.until === 'number' && Date.now() < parsed.until) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (parsed.until == null && parsed.key === key) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 브라우저 방문(세션) 동안만 어나운스 바를 숨긴다.
|
||||
* @param {{ updatedAt?: string | null }} settings - 사이트 설정
|
||||
* @returns {void}
|
||||
*/
|
||||
export const dismissAnnouncementForSession = (settings) => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = getAnnouncementDismissKey(settings)
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionStorage.setItem(ANNOUNCEMENT_SESSION_DISMISS_KEY, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 일수 동안 어나운스 바를 숨긴다.
|
||||
* @param {{ updatedAt?: string | null }} settings - 사이트 설정
|
||||
* @param {number} [days=ANNOUNCEMENT_SNOOZE_DAYS] - 숨김 일수
|
||||
* @returns {void}
|
||||
*/
|
||||
export const dismissAnnouncementForDays = (settings, days = ANNOUNCEMENT_SNOOZE_DAYS) => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = getAnnouncementDismissKey(settings)
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
const until = Date.now() + days * 24 * 60 * 60 * 1000
|
||||
localStorage.setItem(ANNOUNCEMENT_DISMISS_STORAGE_KEY, JSON.stringify({ key, until }))
|
||||
sessionStorage.setItem(ANNOUNCEMENT_SESSION_DISMISS_KEY, key)
|
||||
}
|
||||
27
lib/brand-color.js
Normal file
27
lib/brand-color.js
Normal file
@@ -0,0 +1,27 @@
|
||||
export const DEFAULT_BRAND_COLOR = '#ff4f2e'
|
||||
|
||||
/**
|
||||
* 브랜드 컬러 형식이 올바른지 확인한다.
|
||||
* @param {unknown} value - 검사할 값
|
||||
* @returns {boolean} 유효한 색상 여부
|
||||
*/
|
||||
export const isValidBrandColor = (value) => /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || '').trim())
|
||||
|
||||
/**
|
||||
* 브랜드 컬러를 6자리 hex 값으로 정규화한다.
|
||||
* @param {unknown} value - 정규화할 색상 값
|
||||
* @returns {string} 정규화된 색상
|
||||
*/
|
||||
export const normalizeBrandColor = (value) => {
|
||||
const color = String(value || '').trim().toLowerCase()
|
||||
|
||||
if (!isValidBrandColor(color)) {
|
||||
return DEFAULT_BRAND_COLOR
|
||||
}
|
||||
|
||||
if (color.length === 4) {
|
||||
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
32
lib/external-favicon-url.js
Normal file
32
lib/external-favicon-url.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 외부 사이트 파비콘을 브라우저에 표시하기 위한 프록시 URL을 만든다.
|
||||
* 호스트만 추출해 Google Favicon 서비스(`https://www.google.com/s2/favicons`) URL을 반환한다.
|
||||
* 내부 경로(`/…`)·`#`·파싱 실패 시 빈 문자열을 반환한다(이미지 생략).
|
||||
* @param {string} rawUrl - 링크 URL
|
||||
* @param {number} [sizePx] - 한 변(px), 기본 32, 최대 128
|
||||
* @returns {string} `https://www.google.com/s2/favicons?...` 또는 `''`
|
||||
*/
|
||||
export const getExternalFaviconUrl = (rawUrl, sizePx = 32) => {
|
||||
const trimmed = String(rawUrl || '').trim()
|
||||
if (!trimmed || trimmed === '#') {
|
||||
return ''
|
||||
}
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const withProto = trimmed.startsWith('//') ? `https:${trimmed}` : trimmed
|
||||
const u = new URL(withProto)
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
return ''
|
||||
}
|
||||
const host = u.hostname
|
||||
if (!host) {
|
||||
return ''
|
||||
}
|
||||
const sz = Number.isFinite(sizePx) && sizePx > 0 ? Math.min(128, Math.round(sizePx)) : 32
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=${sz}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user