Compare commits
404 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 488889cee1 | |||
| 0cd888a427 | |||
| 7b6fc45bf3 | |||
| 51d5de148f | |||
| 184a971863 | |||
| 2d4431e698 | |||
| 345de36bd1 | |||
| 9e2fec6d6c | |||
| 93dc6c04bd | |||
| 7c95797fd2 | |||
| c8e00c1190 | |||
| 6886c57d04 | |||
| 5482318b84 | |||
| 0fe167a1ec | |||
| 2b232d0b51 | |||
| 1453026e60 | |||
| 801ae7ae89 | |||
| 5600a20294 | |||
| 29fba4f780 | |||
| 2f1314b46e | |||
| f3170f3908 | |||
| 93eb5dd96d | |||
| 9113711a8a | |||
| c4493bb7b2 | |||
| 23f0e9b3ef | |||
| 65924292b4 | |||
| 8a6963e8e1 | |||
| 378afbe79c | |||
| 4e7e12d8fe | |||
| 1a5a1542b7 | |||
| 3c431576b7 | |||
| 52ed053876 | |||
| 99af25b8fa | |||
| 30f7b22340 | |||
| 3698032886 | |||
| ebc4a95b37 | |||
| 92e37f3ae8 | |||
| ae9883a588 | |||
| 77271399cc | |||
| 393d173af5 | |||
| d8cfa77080 | |||
| 980ef92a68 | |||
| 57dac3f81a | |||
| d709b5d94c | |||
| 00d58fe569 | |||
| 737d064b06 | |||
| 126f608808 | |||
| db7cf62c92 | |||
| bf7a0f247e | |||
| b953001f32 | |||
| 9f3ef337ef | |||
| 5e4ccbf2bf | |||
| a0e126b2a9 | |||
| e5e4febbbc | |||
| a122ef46ac | |||
| b3f3cbd0ec | |||
| cb6e368375 | |||
| 20ea9169c6 | |||
| a8119d22ca | |||
| 1d15a0e4d1 | |||
| 9efa6d6af5 | |||
| 16ea10229e | |||
| ff2d78ff81 | |||
| 7c19447ec8 | |||
| 5fb2781d72 | |||
| 8c4ad90f91 | |||
| b15282f41f | |||
| bf48af7bd3 | |||
| 358199e595 | |||
| 022cfa2ba5 | |||
| 5803e1927c | |||
| a39944b139 | |||
| 3515cf0ccf | |||
| 675c45092b | |||
| 736f7d24ec | |||
| 3cc99ebeec | |||
| ab1b2a32c1 | |||
| 63c657d7a3 | |||
| ce64ab7a9f | |||
| 622f19235e | |||
| 636d4f68a4 | |||
| f4a2f1c034 | |||
| 84cb3ad9e7 | |||
| 48982cfa52 | |||
| a188229489 | |||
| b9771778dc | |||
| fe1a390699 | |||
| 408d4aa420 | |||
| 1ec71cc985 | |||
| e3f98cb820 | |||
| 6cc820d2a2 | |||
| 401dc64238 | |||
| bad5a9081a | |||
| 31cae6f90b | |||
| 7bc04ec5b9 | |||
| 4b800d6d2e | |||
| 8abe130f8d | |||
| 540255512c | |||
| e8eaed6ff5 | |||
| 73248da016 | |||
| d67f735978 | |||
| e065798b78 | |||
| 2d40fc0574 | |||
| b38eb234d8 | |||
| 24dc6b8af0 | |||
| 544d4cb1c1 | |||
| c833d47a2c | |||
| 5f6d950721 | |||
| 578ee889ee | |||
| 7481773085 | |||
| 1f68524548 | |||
| d764865220 | |||
| f996506a15 | |||
| 164bc2131f | |||
| 191a9bb48d | |||
| 5b6e4d33dd | |||
| b2537d4d14 | |||
| e6c78f8686 | |||
| 46c9d8d983 | |||
| 8bb6784c66 | |||
| 3c60ed48b9 | |||
| 0a6a69a843 | |||
| f7bf73a719 | |||
| 526c9b9de9 | |||
| 6b8fd27423 | |||
| 47c1b02d2b | |||
| d885b844ad | |||
| ff18824176 | |||
| 5bd85a100a | |||
| aa01be3688 | |||
| eb27d2846f | |||
| a76dcc8111 | |||
| ab974c8988 | |||
| eb140128f5 | |||
| cc10de86b0 | |||
| 8249921306 | |||
| 626464df4a | |||
| 2ca2cd5130 | |||
| ddebb5ce93 | |||
| ac26f41abd | |||
| c6f43bab59 | |||
| d9cd7bce97 | |||
| ef7c1216e4 | |||
| 85b6fe7033 | |||
| f510a88e56 | |||
| 4783881829 | |||
| 39e05a1038 | |||
| 7dc1ecef4d | |||
| 60512ef07d | |||
| 57c4363db2 | |||
| 30e5071cba | |||
| 24e3d516b3 | |||
| 1a8d779e35 | |||
| 611abb5a76 | |||
| f1bc980b61 | |||
| 180213f6ed | |||
| 151349edc2 | |||
| b61a6a2295 | |||
| 5e619bbe43 | |||
| 89ee25bef0 | |||
| de767095ba | |||
| 9bc0f0a90e | |||
| 8809183d69 | |||
| c0684737ce | |||
| e7c4f952a6 | |||
| 14c53826c6 | |||
| 7aa3dfa185 | |||
| ad38644171 | |||
| cf7d6ca0de | |||
| 04c03ad604 | |||
| d708381e09 | |||
| e48f886956 | |||
| 146487dbe6 | |||
| e27ae1ea72 | |||
| 14c7245f06 | |||
| eb840b4224 | |||
| 976027c808 | |||
| 2e5a973d11 | |||
| 8c84d2536c | |||
| 4c8f6e44f6 | |||
| 021c1ee526 | |||
| 7685f82c77 | |||
| 34d2d8e2ea | |||
| 6cb39603bf | |||
| cb5cc68ecf | |||
| a59d17fefb | |||
| 9952a05ec2 | |||
| 8a774a1874 | |||
| a07d0350c2 | |||
| 393b0edd70 | |||
| 5db41e9883 | |||
| 16343b62c7 | |||
| 3566144783 | |||
| 0cd691adf4 | |||
| bb8d20f138 | |||
| b669d34e83 | |||
| 58fbae5a03 | |||
| 33c92a6f72 | |||
| 288e73c0dc | |||
| 55efae810f | |||
| 243dfbc5e2 | |||
| 955ae90697 | |||
| 94782d5873 | |||
| 3f80bbb4ea | |||
| b4e408dec8 | |||
| 7f20949669 | |||
| 0576069427 | |||
| 00cb4a293d | |||
| 3dd30d7f76 | |||
| 7c9b5e9278 | |||
| 486c8bfe76 | |||
| ffd6cbb822 | |||
| 3a4e39332a | |||
| b26ae271d2 | |||
| 445eb39a83 | |||
| e5d3eac2fd | |||
| 129b68b939 | |||
| 78ab394f7a | |||
| 943794ae34 | |||
| c686b1c848 | |||
| 0eca6b288e | |||
| 853bec747e | |||
| d3f08e743d | |||
| 1a7b82d9fd | |||
| ce20ce1d6d | |||
| 60a0a6a47a | |||
| c6ca46bbb1 | |||
| 1a95e9c2fd | |||
| 7ec07bb7ea | |||
| e1455e9064 | |||
| 946bbd5b09 | |||
| 00999404a4 | |||
| 6a3cc5d7ec | |||
| 4e2ec3d7a2 | |||
| 901463e5f3 | |||
| ef9efd7af5 | |||
| 67ea58ccc2 | |||
| 6d74425415 | |||
| a39317f25c | |||
| 066c58f311 | |||
| 5247eb293c | |||
| b7362df274 | |||
| 8e3bf4a874 | |||
| c77cf1032b | |||
| a93780731c | |||
| cab71285d8 | |||
| c2acecffcd | |||
| e2ca365fa0 | |||
| 7cd32f62bd | |||
| db1030bfcd | |||
| 6279a046a9 | |||
| 06264e9b02 | |||
| 5b2ed97748 | |||
| f206499f99 | |||
| 7107d56fc7 | |||
| d98114003d | |||
| 54d32af617 | |||
| 11f8402cb6 | |||
| d41cb71d40 | |||
| 6c4064d8b5 | |||
| 0bb07b5e03 | |||
| 06271b3674 | |||
| eba7704ab8 | |||
| 95d234a625 | |||
| ed30926250 | |||
| e6669439f3 | |||
| 4b18ee78f0 | |||
| 080be1ef15 | |||
| 34b2d0a4c0 | |||
| b69039c7ff | |||
| f7f09ba3aa | |||
| eee65a3b43 | |||
| 4599e7630d | |||
| 61a872315b | |||
| 03cd95fbd0 | |||
| 806b181d1f | |||
| eb4018f92c | |||
| 664d2f98aa | |||
| 7a357dcabc | |||
| 28d95129c2 | |||
| 60ca6e0930 | |||
| ccb6db5f89 | |||
| 629ef8c4c6 | |||
| cc9e5949fa | |||
| 5c93643949 | |||
| 9a4820e69c | |||
| 928b8446b4 | |||
| 4c0875446b | |||
| 16a12d304d | |||
| 6daf9ca15e | |||
| 56a2c23471 | |||
| 09b6c51048 | |||
| 264f551cb4 | |||
| 94226423c7 | |||
| 67fbba3814 | |||
| f048eaac2b | |||
| 648ce5fbab | |||
| 35b9893eab | |||
| 675e6bca78 | |||
| 83f66a4b93 | |||
| accd933e99 | |||
| 24611af8b6 | |||
| b38fc9f154 | |||
| 2cb1ff4651 | |||
| b5970c8ada | |||
| 79009b21e6 | |||
| 21d01632be | |||
| b3c7f26d10 | |||
| 4da1ade2cf | |||
| e3b8087b09 | |||
| ba17e3aa18 | |||
| 093d09c8bf | |||
| 1bcd2f6898 | |||
| 5b78a8c92f | |||
| 600b0fd1d9 | |||
| 1a670e237f | |||
| 0e2b701862 | |||
| e2df9d55ac | |||
| c2e69d9048 | |||
| 9d91355c81 | |||
| ef1a9d9032 | |||
| 5732a27498 | |||
| 212bd3f34f | |||
| 5735fd5046 | |||
| a4c1b42369 | |||
| f8621d49d8 | |||
| 7c8245c4e9 | |||
| 11203ba251 | |||
| abce690546 | |||
| 052ce316df | |||
| 71046ed883 | |||
| dc50780ff8 | |||
| edbbd3c83c | |||
| ac57ff458d | |||
| cb92b32f9c | |||
| 602106ac9d | |||
| 7f017a03a5 | |||
| 8ca63c0d00 | |||
| fd9416c0e4 | |||
| d7a3149ea1 | |||
| e78e09f3fd | |||
| a5ae2c3fce | |||
| 3843e16d9f | |||
| 6333c4254f | |||
| b989193dab | |||
| 62ceaa3591 | |||
| a25306389b | |||
| 0ad2ab3f9d | |||
| 6536465b12 | |||
| dcd1060ec7 | |||
| 38ca3a4709 | |||
| 8f53210756 | |||
| 9e5728074a | |||
| 10c5a099fc | |||
| 6919669330 | |||
| 095a8fa5f0 | |||
| f8e04003fd | |||
| a396d1d022 | |||
| cc34db40f2 | |||
| 0e70d4482d | |||
| c43873ce5f | |||
| abb77dbb4d | |||
| 3623305119 | |||
| b6a3228b09 | |||
| b77f37a94e | |||
| 02d33996c5 | |||
| 797a6dd5a0 | |||
| 3fb8a40031 | |||
| 666bd304fc | |||
| 0c051cbe3b | |||
| c9b484e4c8 | |||
| a867269d9b | |||
| c474a8b9a3 | |||
| 47620ab24c | |||
| 14ce897bf8 | |||
| 6fd61911fd | |||
| 2074b0b93a | |||
| ca1e17890b | |||
| 2768975752 | |||
| 59a50a0c97 | |||
| b4e4e37f5a | |||
| 536ee7079e | |||
| 9e544d97fa | |||
| 20b901d4a1 | |||
| 0ed848a2eb | |||
| 08f0aa0efa | |||
| 17dcd04339 | |||
| 36625de1eb | |||
| 62e501f8d0 | |||
| 849e86802f | |||
| 5da93b9aa4 | |||
| 113c974ee5 | |||
| f5bfb560e2 | |||
| 941355cae9 | |||
| 91b7369a07 | |||
| 5eb6c88381 | |||
| eab81697e5 | |||
| 88a0860078 | |||
| 35c378c8f5 | |||
| bd0e2ad120 | |||
| 1b035de16c | |||
| 4862b52b3a | |||
| 6367e62ef0 | |||
| 1487e9da76 |
@@ -5,6 +5,12 @@ dist
|
||||
coverage
|
||||
.git
|
||||
.env
|
||||
.env.development
|
||||
.env.production
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
**/.DS_Store
|
||||
public/uploads
|
||||
tests
|
||||
test-results
|
||||
playwright-report
|
||||
docs
|
||||
|
||||
28
.env.example
28
.env.example
@@ -4,16 +4,29 @@ DATABASE_NAME=sori_studio
|
||||
POSTGRES_DB=sori_studio
|
||||
POSTGRES_USER=sori_studio
|
||||
POSTGRES_PASSWORD=replace-with-random-password
|
||||
DB_PORT=43119
|
||||
DB_BIND_ADDRESS=127.0.0.1
|
||||
DB_PORT=43081
|
||||
|
||||
# Auth
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
# Google 로그인/계정 연동(optional)
|
||||
# GOOGLE_OAUTH_CLIENT_ID=
|
||||
# GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
# GOOGLE_OAUTH_REDIRECT_URI=https://sori.studio/api/auth/google/callback
|
||||
# ANALYTICS_HASH_SECRET= ← 선택. 일별 방문자 해시용 비밀. 비우면 MEMBER_SESSION_SECRET을 대신 사용.
|
||||
# ANALYTICS_COLLECTED_SINCE=2026-06-01 ← 선택. 관리자 통계 집계 시작일(YYYY-MM-DD).
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
# 마이그레이션·reconcile 완료 후 database. 신규 설치는 filesystem으로 시작한 뒤 reconcile → database.
|
||||
MEDIA_CATALOG_MODE=database
|
||||
MAX_FILE_SIZE=10485760
|
||||
MAX_VIDEO_FILE_SIZE=209715200
|
||||
MAX_AUDIO_FILE_SIZE=52428800
|
||||
MAX_DOCUMENT_FILE_SIZE=52428800
|
||||
POST_EXPORT_MAX_FILE_SIZE_BYTES=524288000
|
||||
AVATAR_MIN_WIDTH=96
|
||||
AVATAR_MIN_HEIGHT=96
|
||||
AVATAR_MAX_WIDTH=512
|
||||
@@ -24,10 +37,21 @@ AVATAR_WEBP_QUALITY=82
|
||||
NUXT_PUBLIC_SITE_URL=https://sori.studio
|
||||
NUXT_PUBLIC_SITE_TITLE=sori.studio
|
||||
|
||||
# Google AdSense Management API (optional — 관리자 대시보드 수익 통계)
|
||||
# GOOGLE_ADSENSE_CLIENT_ID=
|
||||
# GOOGLE_ADSENSE_CLIENT_SECRET=
|
||||
# GOOGLE_ADSENSE_REFRESH_TOKEN=
|
||||
# GOOGLE_ADSENSE_ACCOUNT_NAME=accounts/pub-1234567890123456
|
||||
# GOOGLE_ADSENSE_CURRENCY_CODE=KRW
|
||||
# GOOGLE_ADSENSE_CREDENTIALS_PATH= ← 선택. 토큰 발급 스크립트에서 credentials.json을 직접 읽을 때만 사용.
|
||||
# GOOGLE_ADSENSE_OAUTH_PORT=43082
|
||||
|
||||
# Transactional email (Resend, optional — 회원가입 OTP·비밀번호 찾기)
|
||||
# RESEND_API_KEY=
|
||||
# RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
# EMAIL_OTP_PEPPER= ← 선택. OTP를 DB에 해시해 저장할 때 섞는 서버 전용 비밀(긴 난문자열 권장, 예: openssl rand -hex 32). 비우면 MEMBER_SESSION_SECRET을 대신 사용.
|
||||
|
||||
# Server
|
||||
APP_PORT=43118
|
||||
COMPOSE_PROJECT_NAME=sori-studio
|
||||
APP_PORT=43080
|
||||
DOCKER_SUBNET=10.250.50.0/24
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,6 +11,7 @@ public/uploads/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
docker-compose.yaml.backup.*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -28,6 +29,8 @@ npm-debug.log*
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Reference theme sample (do not commit)
|
||||
ZCF-v1.0.5/
|
||||
|
||||
@@ -22,6 +22,11 @@ ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
COPY --from=builder /app/scripts/backfill-post-thumbnails.js ./.output/server/scripts/backfill-post-thumbnails.js
|
||||
COPY --from=builder /app/scripts/backfill-post-body-thumbnails.js ./.output/server/scripts/backfill-post-body-thumbnails.js
|
||||
COPY --from=builder /app/scripts/backfill-page-cover-thumbnails.js ./.output/server/scripts/backfill-page-cover-thumbnails.js
|
||||
COPY --from=builder /app/server/utils/post-thumbnail-image.js ./.output/server/server/utils/post-thumbnail-image.js
|
||||
COPY --from=builder /app/lib/post-card-thumbnail-url.js ./.output/server/lib/post-card-thumbnail-url.js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
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>
|
||||
53
app.vue
53
app.vue
@@ -1,15 +1,18 @@
|
||||
<script setup>
|
||||
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'sori.studio',
|
||||
faviconUrl: ''
|
||||
})
|
||||
})
|
||||
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from './lib/brand-color.js'
|
||||
|
||||
const { data: appSiteSettings } = await usePublicSiteSettings()
|
||||
|
||||
const siteAccentColor = computed(() => normalizeBrandColor(appSiteSettings.value?.brandColor || DEFAULT_BRAND_COLOR))
|
||||
|
||||
const siteAccentStyle = computed(() => ({
|
||||
'--site-accent': siteAccentColor.value
|
||||
}))
|
||||
|
||||
const isSitePageNavigating = useState('site-page-veil', () => false)
|
||||
|
||||
useHead(() => ({
|
||||
titleTemplate: (titleChunk) => titleChunk
|
||||
? `${titleChunk} · ${appSiteSettings.value.title}`
|
||||
: appSiteSettings.value.title,
|
||||
titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title,
|
||||
link: appSiteSettings.value.faviconUrl
|
||||
? [
|
||||
{
|
||||
@@ -23,7 +26,33 @@ useHead(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<div class="site-app" :style="siteAccentStyle">
|
||||
<div
|
||||
v-show="isSitePageNavigating"
|
||||
class="site-page-loading pointer-events-none fixed inset-0 z-[2147483647] grid place-items-center text-[var(--site-text)]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="site-page-loading__panel grid place-items-center gap-3 text-center">
|
||||
<span class="site-page-loading__spinner size-8 rounded-full border-2 border-[var(--site-line)] border-t-[var(--site-accent)]" aria-hidden="true" />
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] opacity-70">Loading</span>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-page-loading__spinner {
|
||||
animation: site-page-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes site-page-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
25
app/router.options.js
Normal file
25
app/router.options.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { setPendingScrollRestore } from '../lib/site-route-transition.js'
|
||||
|
||||
/**
|
||||
* 라우터 스크롤 동작
|
||||
* 클라이언트에서는 scrollBehavior가 직접 스크롤하지 않고,
|
||||
* 전환 베일 뒤에서 `site-page-transition.client.js`가 스크롤을 적용한다.
|
||||
* @type {import('vue-router').RouterScrollBehavior}
|
||||
*/
|
||||
export default (to, from, savedPosition) => {
|
||||
if (import.meta.server) {
|
||||
return { top: 0, left: 0 }
|
||||
}
|
||||
|
||||
if (to.hash) {
|
||||
return {
|
||||
el: to.hash
|
||||
}
|
||||
}
|
||||
|
||||
if (savedPosition) {
|
||||
setPendingScrollRestore(savedPosition)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -64,6 +94,34 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.site-navigating {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
html.site-navigating a,
|
||||
html.site-navigating button,
|
||||
html.site-navigating [role="button"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html.site-page-veil {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.site-page-veil::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
background: var(--site-bg);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
html.site-page-veil .site-shell,
|
||||
html.site-page-veil .site-shell * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
margin: 0;
|
||||
@@ -76,6 +134,13 @@
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
html.admin-settings-document,
|
||||
body.admin-settings-document {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -116,6 +181,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 +225,7 @@
|
||||
|
||||
.site-sidebar {
|
||||
min-height: 0;
|
||||
background: var(--site-panel);
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
@@ -216,6 +302,108 @@
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 카드 제목 링크 공통 인터랙션 — 메인·전체 목록 통일.
|
||||
* 데스크톱은 실제 hover 가능 기기에서만 색을 바꾸고, 모바일은 누르는 순간(:active)에만 반영해
|
||||
* 탭 후 색이 남는 sticky hover와 회색 탭 하이라이트를 없앤다.
|
||||
*/
|
||||
.post-title-link {
|
||||
color: var(--site-text);
|
||||
transition: color 0.15s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.post-title-link:hover {
|
||||
color: var(--site-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.post-title-link:active {
|
||||
color: var(--site-accent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 카드 보조 피드백 — 제목 강조와 함께 썸네일을 은은하게 확대.
|
||||
* 데스크톱은 카드 hover(실제 hover 기기), 모바일은 카드/썸네일 :active 눌림에만 반영.
|
||||
*/
|
||||
.post-card-media__image,
|
||||
.post-card-media__placeholder {
|
||||
transition: transform 0.3s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.group:hover .post-card-media__image,
|
||||
.group:hover .post-card-media__placeholder {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
.group:active .post-card-media__image,
|
||||
.group:active .post-card-media__placeholder,
|
||||
.post-card-media:active .post-card-media__image,
|
||||
.post-card-media:active .post-card-media__placeholder {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/**
|
||||
* 왼쪽 사이드바 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 +424,14 @@
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
173
components/admin/AdminAdsSettingsCard.vue
Normal file
173
components/admin/AdminAdsSettingsCard.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
const adSlots = [
|
||||
{
|
||||
key: 'adHomeFeedCode',
|
||||
label: '메인 피드',
|
||||
description: '메인 화면 Featured와 Latest 목록 사이에 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
key: 'adHomeInfeedCode',
|
||||
label: '메인 인피드',
|
||||
description: '메인 화면 Latest 게시물 목록 사이 한 곳에 무작위로 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
key: 'adSidebarCode',
|
||||
label: '오른쪽 사이드',
|
||||
description: '게시물 상세를 제외한 공개 화면의 오른쪽 사이드바 하단에 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
key: 'adPostSidebarCode',
|
||||
label: '게시물 오른쪽 사이드',
|
||||
description: '게시물 상세 화면의 오른쪽 사이드바 TOC 아래에 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
key: 'adPostTopCode',
|
||||
label: '게시물 본문 상단',
|
||||
description: '게시물 상세 본문 렌더링 직전에 표시됩니다.',
|
||||
placeholder: '<script async src="..."><' + '/script>\n<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
key: 'adPostInArticleCode',
|
||||
label: '게시물 인아티클',
|
||||
description: '게시물 본문이 충분히 길 때 본문 중간 문단 뒤에 표시되며, 긴 글은 최대 두 번 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
key: 'adPostBottomCode',
|
||||
label: '게시물 본문 하단',
|
||||
description: '게시물 상세 본문 렌더링 직후에 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>\n<script>(adsbygoogle = window.adsbygoogle || []).push({})<' + '/script>'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 광고 슬롯 코드 등록 여부를 반환한다.
|
||||
* @param {Object} form - 사이트 설정 폼
|
||||
* @param {string} key - 슬롯 키
|
||||
* @returns {boolean} 등록 여부
|
||||
*/
|
||||
const hasSlotCode = (form, key) => Boolean(String(form?.[key] || '').trim())
|
||||
|
||||
/**
|
||||
* 광고 설정 카드
|
||||
* @property {Object} form - 사이트 설정 폼 객체
|
||||
* @property {boolean} editing - 편집 모드 여부
|
||||
* @property {boolean} saving - 저장 중 여부
|
||||
* @property {boolean} hasChanges - 변경 여부
|
||||
*/
|
||||
defineProps({
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasChanges: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['begin', 'cancel', 'save'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-ads"
|
||||
class="admin-ads-settings-card admin-settings-screen__card admin-settings-screen__card--ads relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
|
||||
Ads
|
||||
</h2>
|
||||
<p
|
||||
v-if="!editing"
|
||||
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
|
||||
>
|
||||
위치별 광고 코드를 관리합니다. 비어 있는 슬롯은 공개 화면에 표시되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
|
||||
<template v-if="!editing">
|
||||
<button
|
||||
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
|
||||
type="button"
|
||||
@click="$emit('begin')"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="saving || !hasChanges"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ saving ? '저장 중' : '저장' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!editing"
|
||||
class="admin-ads-settings-card__readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
|
||||
>
|
||||
<div
|
||||
v-for="slot in adSlots"
|
||||
:key="slot.key"
|
||||
class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[9rem_minmax(0,1fr)] md:items-center md:gap-5"
|
||||
>
|
||||
<p class="font-normal text-[#3f4650]">
|
||||
{{ slot.label }}
|
||||
</p>
|
||||
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
|
||||
{{ hasSlotCode(form, slot.key) ? '등록됨' : '미등록' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="admin-ads-settings-card__edit grid gap-5 border-t border-[#eceff2] pt-5"
|
||||
>
|
||||
<label
|
||||
v-for="slot in adSlots"
|
||||
:key="slot.key"
|
||||
class="admin-settings-screen__field grid gap-2 text-sm"
|
||||
>
|
||||
<span class="font-medium text-[#3f4650]">{{ slot.label }}</span>
|
||||
<p class="text-xs leading-relaxed text-[#657080]">
|
||||
{{ slot.description }} 애드센스에서 제공한 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣습니다.
|
||||
</p>
|
||||
<textarea
|
||||
v-model="form[slot.key]"
|
||||
class="min-h-[9rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
rows="7"
|
||||
spellcheck="false"
|
||||
:placeholder="slot.placeholder"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
401
components/admin/AdminAnalyticsTrendChart.vue
Normal file
401
components/admin/AdminAnalyticsTrendChart.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<script setup>
|
||||
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
|
||||
import {
|
||||
ANALYTICS_SINGLE_BAR_COLORS,
|
||||
formatAnalyticsChartHeaderLabel,
|
||||
getAnalyticsPeriodLabel,
|
||||
getAnalyticsTrafficStackColor,
|
||||
getAnalyticsTrafficStackSegments,
|
||||
getAnalyticsTrendAxisPrimaryLabel,
|
||||
getAnalyticsTrendAxisSecondaryLabel,
|
||||
getAnalyticsTrendDelta,
|
||||
getAnalyticsTrendDetailLabel,
|
||||
isAnalyticsCurrentTrendRow,
|
||||
paginateAnalyticsTrendRows
|
||||
} from '~/lib/analytics-chart-ui.js'
|
||||
import { getAnalyticsDayKey } from '~/lib/analytics-shared.js'
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
period: {
|
||||
type: String,
|
||||
default: 'weekly'
|
||||
},
|
||||
metricKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
metricLabel: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
formatValue: {
|
||||
type: Function,
|
||||
default: (value) => formatAnalyticsCount(value)
|
||||
},
|
||||
formatDelta: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
pageOffset: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
showNavigation: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
barHeightClass: {
|
||||
type: String,
|
||||
default: 'h-32'
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: '선택한 기간에 표시할 추이 데이터가 없습니다.'
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
trafficStack: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:pageOffset', 'bar-click'])
|
||||
|
||||
const todayKey = getAnalyticsDayKey()
|
||||
const hoveredBarKey = ref(null)
|
||||
|
||||
const pagination = computed(() => {
|
||||
return paginateAnalyticsTrendRows(props.rows, props.period, props.pageOffset)
|
||||
})
|
||||
|
||||
const visibleRows = computed(() => pagination.value.rows)
|
||||
|
||||
const chartHeaderLabel = computed(() => {
|
||||
return formatAnalyticsChartHeaderLabel(props.period, visibleRows.value, todayKey)
|
||||
})
|
||||
|
||||
/**
|
||||
* 추이 행의 고유 키를 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {string} 고유 키
|
||||
*/
|
||||
const getBarRowKey = (row) => {
|
||||
return `${row.startDay || row.day}-${row.endDay || row.day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대가 강조 상태인지 확인한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {boolean} 강조 여부
|
||||
*/
|
||||
const isBarEmphasized = (row) => {
|
||||
if (!hoveredBarKey.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getBarRowKey(row) === hoveredBarKey.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대가 흐리게 표시되는지 확인한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {boolean} 흐림 여부
|
||||
*/
|
||||
const isBarDimmed = (row) => {
|
||||
return Boolean(hoveredBarKey.value && !isBarEmphasized(row))
|
||||
}
|
||||
|
||||
/**
|
||||
* 축 라벨이 강조 상태인지 확인한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {boolean} 강조 여부
|
||||
*/
|
||||
const isAxisHighlighted = (row) => {
|
||||
return isBarEmphasized(row) || (isAnalyticsCurrentTrendRow(props.period, row, todayKey) && !hoveredBarKey.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대 높이 비율을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {number} 높이 %
|
||||
*/
|
||||
const getBarHeight = (row) => {
|
||||
const maxValue = Math.max(...visibleRows.value.map((item) => Number(item[props.metricKey] || 0)), 0)
|
||||
|
||||
if (maxValue <= 0) {
|
||||
return 3
|
||||
}
|
||||
|
||||
const value = Number(row[props.metricKey] || 0)
|
||||
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 막대 색상을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {string} 색상
|
||||
*/
|
||||
const getSingleBarColor = (row) => {
|
||||
const emphasized = isBarEmphasized(row)
|
||||
return emphasized ? ANALYTICS_SINGLE_BAR_COLORS.active : ANALYTICS_SINGLE_BAR_COLORS.muted
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 스택 구간 목록을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {Array<{ key: 'search' | 'sns' | 'other', value: number }>}
|
||||
*/
|
||||
const getStackSegments = (row) => {
|
||||
return getAnalyticsTrafficStackSegments(row).segments
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 추이 행에서 현재 행 인덱스를 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {number} 인덱스
|
||||
*/
|
||||
const getRowIndexInAllRows = (row) => {
|
||||
const key = getBarRowKey(row)
|
||||
return props.rows.findIndex((item) => getBarRowKey(item) === key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 증감 표시 문자열을 반환한다.
|
||||
* @param {number | null} delta - 증감값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatDeltaLabel = (delta) => {
|
||||
if (delta === null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof props.formatDelta === 'function') {
|
||||
return props.formatDelta(delta)
|
||||
}
|
||||
|
||||
const absolute = Math.abs(delta)
|
||||
return formatAnalyticsCount(absolute)
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대 hover 시작을 처리한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBarEnter = (row) => {
|
||||
hoveredBarKey.value = getBarRowKey(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대 hover 종료를 처리한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBarLeave = () => {
|
||||
hoveredBarKey.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 페이지로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const goPrevPage = () => {
|
||||
if (!pagination.value.canGoPrev) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:pageOffset', props.pageOffset + pagination.value.pageSize)
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 페이지로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const goNextPage = () => {
|
||||
if (!pagination.value.canGoNext) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:pageOffset', Math.max(props.pageOffset - pagination.value.pageSize, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대 클릭을 처리한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBarClick = (row) => {
|
||||
if (!props.clickable) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('bar-click', row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 추이 행의 증감값을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {number | null} 증감값
|
||||
*/
|
||||
const getRowDelta = (row) => {
|
||||
return getAnalyticsTrendDelta(props.rows, getRowIndexInAllRows(row), props.metricKey)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-analytics-trend-chart">
|
||||
<header
|
||||
v-if="showHeader && chartHeaderLabel"
|
||||
class="admin-analytics-trend-chart__header mb-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<p class="admin-analytics-trend-chart__range text-sm font-semibold text-ink">
|
||||
{{ chartHeaderLabel }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="admin-analytics-trend-chart__body relative" :class="showNavigation ? 'pb-8' : 'pb-0'">
|
||||
<div
|
||||
v-if="visibleRows.length"
|
||||
class="admin-analytics-trend-chart__columns flex gap-1"
|
||||
role="img"
|
||||
:aria-label="`${getAnalyticsPeriodLabel(period)} ${metricLabel} 추이`"
|
||||
>
|
||||
<div
|
||||
v-for="(row, index) in visibleRows"
|
||||
:key="`${period}-${getBarRowKey(row)}`"
|
||||
class="admin-analytics-trend-chart__column flex min-w-0 flex-1 flex-col transition-opacity duration-150"
|
||||
:class="isBarDimmed(row) ? 'opacity-40' : 'opacity-100'"
|
||||
@mouseenter="handleBarEnter(row)"
|
||||
@mouseleave="handleBarLeave"
|
||||
>
|
||||
<component
|
||||
:is="clickable ? 'button' : 'div'"
|
||||
class="admin-analytics-trend-chart__bar-wrap group relative flex w-full items-end border-b border-line"
|
||||
:class="[barHeightClass, clickable ? 'cursor-pointer border-0 bg-transparent p-0' : '']"
|
||||
:type="clickable ? 'button' : undefined"
|
||||
@focus="handleBarEnter(row)"
|
||||
@click="handleBarClick(row)"
|
||||
>
|
||||
<div
|
||||
class="admin-analytics-trend-chart__tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden min-w-max max-w-none -translate-x-1/2 whitespace-nowrap rounded border border-line bg-white px-2.5 py-1.5 text-xs shadow-lg group-hover:block group-focus-visible:block"
|
||||
>
|
||||
<div class="admin-analytics-trend-chart__tooltip-main flex items-center gap-2">
|
||||
<span class="text-sm font-semibold leading-none text-ink">
|
||||
{{ formatValue(row[metricKey]) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getRowDelta(row) !== null"
|
||||
class="inline-flex items-center gap-0.5 text-[11px] font-semibold leading-none"
|
||||
:class="getRowDelta(row) >= 0 ? 'text-[#e5484d]' : 'text-[#2f6feb]'"
|
||||
>
|
||||
<span aria-hidden="true">{{ getRowDelta(row) >= 0 ? '▲' : '▼' }}</span>
|
||||
<span>{{ formatDeltaLabel(getRowDelta(row)) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="trafficStack && getStackSegments(row).length > 1"
|
||||
class="admin-analytics-trend-chart__tooltip-breakdown mt-1 text-[10px] leading-relaxed text-muted"
|
||||
>
|
||||
<span class="text-[#e5484d]">검색 {{ formatAnalyticsCount(row.trafficSearch) }}</span>
|
||||
·
|
||||
<span class="text-[#12a594]">SNS {{ formatAnalyticsCount(row.trafficSns) }}</span>
|
||||
·
|
||||
<span class="text-[#8b8d98]">기타 {{ formatAnalyticsCount(row.trafficOther) }}</span>
|
||||
</p>
|
||||
<p class="admin-analytics-trend-chart__tooltip-period mt-1 text-[10px] leading-none text-muted">
|
||||
{{ getAnalyticsTrendDetailLabel(period, row, todayKey) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="trafficStack && metricKey === 'visitors'"
|
||||
class="admin-analytics-trend-chart__stack flex w-full flex-col justify-end overflow-hidden rounded-t-sm"
|
||||
:class="compact ? 'mx-auto max-w-[10px]' : ''"
|
||||
:style="{ height: `${getBarHeight(row)}%` }"
|
||||
>
|
||||
<div
|
||||
v-for="segment in getStackSegments(row)"
|
||||
:key="`${getBarRowKey(row)}-${segment.key}`"
|
||||
class="admin-analytics-trend-chart__stack-segment w-full transition-colors duration-150"
|
||||
:style="{
|
||||
flex: `${segment.value} 0 0`,
|
||||
backgroundColor: getAnalyticsTrafficStackColor(segment.key, isBarEmphasized(row))
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="admin-analytics-trend-chart__bar w-full rounded-t-sm transition-colors duration-150"
|
||||
:class="compact ? 'mx-auto max-w-[10px]' : ''"
|
||||
:style="{
|
||||
height: `${getBarHeight(row)}%`,
|
||||
backgroundColor: getSingleBarColor(row)
|
||||
}"
|
||||
/>
|
||||
</component>
|
||||
|
||||
<div class="admin-analytics-trend-chart__axis overflow-visible border-t border-line px-0.5 pt-2 text-center">
|
||||
<span
|
||||
class="admin-analytics-trend-chart__axis-primary inline-flex max-w-full items-center justify-center whitespace-nowrap leading-none transition-colors duration-150"
|
||||
:class="isAxisHighlighted(row) ? 'rounded-full bg-[#15171a] px-1.5 py-0.5 text-[9px] font-semibold !text-white' : 'text-[11px] font-medium text-muted'"
|
||||
>
|
||||
{{ getAnalyticsTrendAxisPrimaryLabel(period, row, index === visibleRows.length - 1, todayKey) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getAnalyticsTrendAxisSecondaryLabel(period, row, visibleRows, index)"
|
||||
class="admin-analytics-trend-chart__axis-secondary mt-0.5 block text-[10px] text-muted"
|
||||
>
|
||||
{{ getAnalyticsTrendAxisSecondaryLabel(period, row, visibleRows, index) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!visibleRows.length"
|
||||
class="admin-analytics-trend-chart__empty mt-4 text-center text-xs text-muted"
|
||||
>
|
||||
{{ emptyMessage }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="showNavigation && visibleRows.length"
|
||||
class="admin-analytics-trend-chart__nav pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between"
|
||||
>
|
||||
<button
|
||||
class="admin-analytics-trend-chart__nav-button pointer-events-auto grid h-7 w-7 place-items-center rounded border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
aria-label="이전 기간"
|
||||
:disabled="!pagination.canGoPrev"
|
||||
@click="goPrevPage"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
class="admin-analytics-trend-chart__nav-button pointer-events-auto grid h-7 w-7 place-items-center rounded border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
aria-label="다음 기간"
|
||||
:disabled="!pagination.canGoNext"
|
||||
@click="goNextPage"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
199
components/admin/AdminAnnouncementSettingsCard.vue
Normal file
199
components/admin/AdminAnnouncementSettingsCard.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup>
|
||||
import {
|
||||
ANNOUNCEMENT_ALIGNMENT_OPTIONS,
|
||||
ANNOUNCEMENT_BACKGROUND_PRESETS,
|
||||
DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR,
|
||||
normalizeAnnouncementAlignment,
|
||||
normalizeAnnouncementBackgroundColor
|
||||
} from '~/lib/announcement-bar.js'
|
||||
|
||||
/**
|
||||
* 어나운스 바 설정 카드
|
||||
* @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-announcement"
|
||||
class="admin-announcement-settings-card admin-settings-screen__card admin-settings-screen__card--announcement 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]"
|
||||
>
|
||||
홈페이지 상단에 중요한 공지나 링크를 표시합니다.<br>방문자는 X로 이번 방문만 닫거나, 7일간 보지 않기를 선택할 수 있습니다.<br>공지를 수정·저장하면 다시 노출됩니다.
|
||||
</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>
|
||||
|
||||
<label
|
||||
v-if="!editing"
|
||||
class="admin-settings-screen__announcement-toggle flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5 text-sm"
|
||||
>
|
||||
<span class="font-bold text-[#15171a]">사용</span>
|
||||
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 cursor-not-allowed items-center opacity-55 grayscale" aria-disabled="true">
|
||||
<input
|
||||
:checked="form.announcementEnabled"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
disabled
|
||||
aria-label="어나운스 바 사용"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#6d747d]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-[#f4f6f8] shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="admin-announcement-settings-card__edit admin-settings-screen__announcement-edit grid gap-6 border-t border-[#eceff2] pt-5"
|
||||
>
|
||||
<label class="admin-settings-screen__announcement-toggle flex items-center justify-between gap-4 text-sm">
|
||||
<span class="font-bold text-[#15171a]">사용</span>
|
||||
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
v-model="form.announcementEnabled"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="어나운스 바 사용"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">어나운스</span>
|
||||
<input
|
||||
v-model="form.announcementText"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
maxlength="200"
|
||||
placeholder="공지 문구"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">링크 (선택)</span>
|
||||
<input
|
||||
v-model="form.announcementUrl"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
maxlength="500"
|
||||
placeholder="https://… 또는 /경로 (비우면 링크 없음)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">배경색</span>
|
||||
<div class="admin-settings-screen__announcement-color-editor flex flex-wrap items-center gap-3">
|
||||
<div class="admin-settings-screen__announcement-colors flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-for="preset in ANNOUNCEMENT_BACKGROUND_PRESETS"
|
||||
:key="preset.id"
|
||||
class="admin-settings-screen__announcement-color relative inline-flex size-9 items-center justify-center rounded-full border transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#15171a]"
|
||||
:class="normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor) === preset.value ? 'border-[#22a06b] ring-2 ring-[#22a06b] ring-offset-2' : 'border-[#dce0e5]'"
|
||||
type="button"
|
||||
:style="{ backgroundColor: preset.value }"
|
||||
:title="preset.label"
|
||||
:aria-label="`${preset.label} 배경`"
|
||||
:aria-pressed="normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor) === preset.value"
|
||||
@click="form.announcementBackgroundColor = preset.value"
|
||||
>
|
||||
<span class="sr-only">{{ preset.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="admin-settings-screen__announcement-color-input inline-flex h-10 items-center gap-2 rounded-md border border-[#dce0e5] bg-white px-2">
|
||||
<input
|
||||
class="size-7 rounded border-0 bg-transparent p-0"
|
||||
type="color"
|
||||
:value="normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR)"
|
||||
aria-label="어나운스 바 배경색 선택"
|
||||
@input="form.announcementBackgroundColor = $event.target.value"
|
||||
>
|
||||
<input
|
||||
v-model="form.announcementBackgroundColor"
|
||||
class="h-8 w-28 border-0 bg-transparent px-1 text-sm font-semibold text-[#15171a] outline-none"
|
||||
maxlength="7"
|
||||
placeholder="#15171a"
|
||||
aria-label="어나운스 바 배경색 직접 입력"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">텍스트 정렬</span>
|
||||
<div class="admin-settings-screen__announcement-alignment inline-flex w-full rounded-md border border-[#dce0e5] bg-white p-1 sm:w-auto">
|
||||
<button
|
||||
v-for="option in ANNOUNCEMENT_ALIGNMENT_OPTIONS"
|
||||
:key="option.value"
|
||||
class="admin-settings-screen__announcement-align-option inline-flex h-9 flex-1 items-center justify-center rounded px-4 text-sm font-semibold transition sm:min-w-24"
|
||||
:class="normalizeAnnouncementAlignment(form.announcementAlignment) === option.value ? 'bg-[#15171a] text-white' : 'text-[#657080] hover:bg-[#f4f6f8] hover:text-[#15171a]'"
|
||||
type="button"
|
||||
:aria-pressed="normalizeAnnouncementAlignment(form.announcementAlignment) === option.value"
|
||||
@click="form.announcementAlignment = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
272
components/admin/AdminBackupSummaryCard.vue
Normal file
272
components/admin/AdminBackupSummaryCard.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
summary: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
pending: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
/**
|
||||
* 사이트 용량 숫자를 쉼표 포함 문자열로 변환한다.
|
||||
* @param {number} value - 숫자 값
|
||||
* @returns {string} 숫자 라벨
|
||||
*/
|
||||
const formatBackupNumber = (value) => Number(value || 0).toLocaleString()
|
||||
|
||||
/**
|
||||
* 사이트 용량 바이트를 읽기 쉬운 문자열로 변환한다.
|
||||
* @param {number} value - 바이트 값
|
||||
* @returns {string} 용량 라벨
|
||||
*/
|
||||
const formatBackupSize = (value) => {
|
||||
const bytes = Number(value || 0)
|
||||
|
||||
if (!bytes) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 용량 기준 시각을 표시한다.
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {string} 날짜 라벨
|
||||
*/
|
||||
const formatBackupGeneratedAt = (value) => {
|
||||
if (!value) {
|
||||
return '집계 대기'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 용량 콘텐츠 지표 목록
|
||||
* @returns {Array<{ label: string, value: string, helper: string }>} 콘텐츠 지표
|
||||
*/
|
||||
const contentMetrics = computed(() => {
|
||||
const content = props.summary?.content || {}
|
||||
const posts = content.posts || {}
|
||||
const pages = content.pages || {}
|
||||
const tags = content.tags || {}
|
||||
const comments = content.comments || {}
|
||||
const members = content.members || {}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '게시글',
|
||||
value: `${formatBackupNumber(posts.total)}개`,
|
||||
helper: `공개 ${formatBackupNumber(posts.published)} · 비공개 ${formatBackupNumber(posts.private)} · 초안 ${formatBackupNumber(posts.draft)} · 회원 ${formatBackupNumber(posts.members)}`
|
||||
},
|
||||
{
|
||||
label: '페이지',
|
||||
value: `${formatBackupNumber(pages.total)}개`,
|
||||
helper: `공개 ${formatBackupNumber(pages.published)} · 목록 ${formatBackupNumber(pages.listed)} · 추천 ${formatBackupNumber(pages.featured)}`
|
||||
},
|
||||
{
|
||||
label: '태그',
|
||||
value: `${formatBackupNumber(tags.total)}개`,
|
||||
helper: `사용 중 ${formatBackupNumber(tags.used)} · 관리 ${formatBackupNumber(tags.managed)} · 일반 ${formatBackupNumber(tags.general)}`
|
||||
},
|
||||
{
|
||||
label: '댓글',
|
||||
value: `${formatBackupNumber(comments.total)}개`,
|
||||
helper: `공개 ${formatBackupNumber(comments.published)} · 대기 ${formatBackupNumber(comments.pending)} · 차단 ${formatBackupNumber(comments.blocked)}`
|
||||
},
|
||||
{
|
||||
label: '회원',
|
||||
value: `${formatBackupNumber(members.total)}명`,
|
||||
helper: `소유자 ${formatBackupNumber(members.owner)} · 관리자 ${formatBackupNumber(members.admin)} · 회원 ${formatBackupNumber(members.member)}`
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 사이트 용량 저장소 행 값을 포맷한다.
|
||||
* @param {number} count - 파일 개수
|
||||
* @param {number} sizeBytes - 파일 용량
|
||||
* @returns {string} 개수·용량 라벨
|
||||
*/
|
||||
const formatBackupStatValue = (count, sizeBytes) => `${formatBackupNumber(count)}개 · ${formatBackupSize(sizeBytes)}`
|
||||
|
||||
/**
|
||||
* 사이트 용량 저장소 섹션 목록
|
||||
* @returns {{ imageRows: Array<{ label: string, value: string, child?: boolean }>, mainRows: Array<{ label: string, value: string }>, totalRow: { label: string, value: string } }} 저장소 섹션
|
||||
*/
|
||||
const storageSections = computed(() => {
|
||||
const storage = props.summary?.storage || {}
|
||||
const derivatives = storage.derivatives || {}
|
||||
const profiles = storage.profiles || {}
|
||||
const exports = storage.exports || {}
|
||||
const byKind = storage.byKind || {}
|
||||
const images = byKind.images || {}
|
||||
const videos = byKind.videos || {}
|
||||
const audio = byKind.audio || {}
|
||||
const files = byKind.files || {}
|
||||
const otherCount = Number(audio.count || 0) + Number(files.count || 0)
|
||||
const otherSize = Number(audio.sizeBytes || 0) + Number(files.sizeBytes || 0)
|
||||
const imageOriginalCount = Math.max(0, Number(images.count || 0) - Number(derivatives.count || 0) - Number(profiles.count || 0))
|
||||
const imageOriginalSize = Math.max(0, Number(images.sizeBytes || 0) - Number(derivatives.sizeBytes || 0) - Number(profiles.sizeBytes || 0))
|
||||
const totalCount = Number(images.count || 0) + Number(videos.count || 0) + otherCount + Number(exports.count || 0)
|
||||
const totalSize = Number(images.sizeBytes || 0) + Number(videos.sizeBytes || 0) + otherSize + Number(exports.sizeBytes || 0)
|
||||
const mainRows = [
|
||||
{
|
||||
label: '영상',
|
||||
value: formatBackupStatValue(videos.count, videos.sizeBytes)
|
||||
},
|
||||
{
|
||||
label: '보내기',
|
||||
value: formatBackupStatValue(exports.count, exports.sizeBytes)
|
||||
}
|
||||
]
|
||||
|
||||
if (Number(audio.count || 0) > 0) {
|
||||
mainRows.push({
|
||||
label: '오디오',
|
||||
value: formatBackupStatValue(audio.count, audio.sizeBytes)
|
||||
})
|
||||
}
|
||||
|
||||
if (Number(files.count || 0) > 0) {
|
||||
mainRows.push({
|
||||
label: '첨부 파일',
|
||||
value: formatBackupStatValue(files.count, files.sizeBytes)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
imageRows: [
|
||||
{
|
||||
label: '이미지',
|
||||
value: formatBackupStatValue(imageOriginalCount, imageOriginalSize)
|
||||
},
|
||||
{
|
||||
label: '자동생성',
|
||||
value: formatBackupStatValue(derivatives.count, derivatives.sizeBytes),
|
||||
child: true
|
||||
},
|
||||
{
|
||||
label: '프로필',
|
||||
value: formatBackupStatValue(profiles.count, profiles.sizeBytes),
|
||||
child: true
|
||||
}
|
||||
],
|
||||
mainRows,
|
||||
totalRow: {
|
||||
label: '합계',
|
||||
value: formatBackupStatValue(totalCount, totalSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-backup-summary"
|
||||
class="admin-backup-summary-card admin-settings-screen__card admin-settings-screen__card--backup-summary rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div class="admin-settings-screen__card-head flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-[#15171a]">
|
||||
사이트 용량
|
||||
</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
콘텐츠 수량과 업로드 파일 용량을 한눈에 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f4f6f8] disabled:cursor-wait disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="pending"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
{{ pending ? '집계 중' : '새로고침' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-2 text-xs text-[#657080]">
|
||||
<span class="rounded-full bg-[#f4f6f8] px-3 py-1">
|
||||
기준 {{ formatBackupGeneratedAt(summary?.generatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-bold text-[#15171a]">콘텐츠</span>
|
||||
<span class="text-xs text-[#657080]">DB 기준</span>
|
||||
</div>
|
||||
<div class="grid gap-2 text-xs text-[#657080] sm:grid-cols-2">
|
||||
<div
|
||||
v-for="metric in contentMetrics"
|
||||
:key="`backup-content-${metric.label}`"
|
||||
class="rounded-md border border-[#edf0f2] px-3 py-2"
|
||||
:class="metric.span ? 'sm:col-span-2' : ''"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ metric.value }}</strong>
|
||||
</div>
|
||||
<p
|
||||
v-if="metric.helper"
|
||||
class="mt-1 text-[11px] leading-relaxed text-[#657080]"
|
||||
>
|
||||
{{ metric.helper }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-bold text-[#15171a]">파일·용량</span>
|
||||
<span class="text-xs text-[#657080]">public/uploads 기준</span>
|
||||
</div>
|
||||
<div class="grid gap-2 text-xs text-[#657080]">
|
||||
<div class="admin-backup-summary-card__image-group admin-settings-screen__backup-image-group grid gap-2">
|
||||
<div
|
||||
v-for="row in storageSections.imageRows"
|
||||
:key="`backup-storage-image-${row.label}`"
|
||||
class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2"
|
||||
:class="row.child ? 'border-dashed' : ''"
|
||||
>
|
||||
<span>{{ row.label }}</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ row.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="row in storageSections.mainRows"
|
||||
:key="`backup-storage-main-${row.label}`"
|
||||
class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2"
|
||||
>
|
||||
<span>{{ row.label }}</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ row.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[#dce0e5] bg-[#fbfcfd] px-3 py-2">
|
||||
<span class="font-bold text-[#15171a]">{{ storageSections.totalRow.label }}</span>
|
||||
<strong class="font-mono font-bold text-[#15171a]">{{ storageSections.totalRow.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
156
components/admin/AdminBrandSettingsCard.vue
Normal file
156
components/admin/AdminBrandSettingsCard.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from '~/lib/brand-color.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 브랜드 컬러 값 */
|
||||
brandColor: {
|
||||
type: String,
|
||||
default: DEFAULT_BRAND_COLOR
|
||||
},
|
||||
/** 편집 모드 여부 */
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 저장 중 여부 */
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 변경 여부 */
|
||||
hasChanges: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['begin', 'cancel', 'save', 'update:brandColor', 'normalize'])
|
||||
|
||||
const normalizedBrandColor = computed(() => normalizeBrandColor(props.brandColor || DEFAULT_BRAND_COLOR))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-brand"
|
||||
class="admin-brand-settings-card admin-settings-screen__card admin-settings-screen__card--brand 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]"
|
||||
>
|
||||
사용자 화면의 포인트 컬러입니다. 활성 네비게이션, TOC, 댓글 버튼 등에 공통으로 적용됩니다.
|
||||
</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 class="grid gap-5 border-t border-[#eceff2] pt-5">
|
||||
<div
|
||||
v-if="!editing"
|
||||
class="admin-brand-settings-card__readonly admin-settings-screen__brand-readonly flex flex-col gap-4 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="size-12 shrink-0 rounded-full border border-black/10"
|
||||
:style="{ backgroundColor: normalizedBrandColor }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-[#15171a]">
|
||||
포인트 컬러
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-sm text-[#657080]">
|
||||
{{ normalizedBrandColor }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex rounded-full px-3 py-1 text-xs font-bold text-white"
|
||||
:style="{ backgroundColor: normalizedBrandColor }"
|
||||
>
|
||||
활성 배지
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex border-b-2 px-1 py-1 text-xs font-bold"
|
||||
:style="{ borderColor: normalizedBrandColor, color: normalizedBrandColor }"
|
||||
>
|
||||
TOC
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-brand-settings-card__edit admin-settings-screen__brand-edit grid gap-4 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-4">
|
||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">브랜드 컬러</span>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
class="h-12 w-full rounded-md border border-[#dce0e5] bg-white px-2 py-1 sm:w-20"
|
||||
type="color"
|
||||
:value="normalizedBrandColor"
|
||||
@input="emit('update:brandColor', $event.target.value)"
|
||||
>
|
||||
<input
|
||||
:value="brandColor"
|
||||
class="h-12 min-w-0 flex-1 rounded-md border border-[#dce0e5] bg-white px-3 font-mono text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="text"
|
||||
placeholder="#ff4f2e"
|
||||
@input="emit('update:brandColor', $event.target.value)"
|
||||
@blur="emit('normalize')"
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
<p class="text-sm leading-relaxed text-[#657080]">
|
||||
3자리 또는 6자리 hex 컬러를 사용할 수 있습니다. 비워두거나 잘못된 값은 기본 오렌지 컬러로 되돌립니다.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
class="inline-flex h-10 items-center rounded-full px-4 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: normalizedBrandColor }"
|
||||
>
|
||||
댓글 등록
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex h-10 items-center border-b-2 px-1 text-sm font-bold"
|
||||
:style="{ borderColor: normalizedBrandColor, color: normalizedBrandColor }"
|
||||
>
|
||||
활성 TOC
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
38
components/admin/AdminCommentList.vue
Normal file
38
components/admin/AdminCommentList.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
|
||||
const { data, error, refresh } = await useFetch('/admin/api/comments', {
|
||||
query: { pageSize: 5 },
|
||||
default: () => ({ items: [], pagination: { total: 0 } })
|
||||
})
|
||||
let refreshTimer = null
|
||||
onMounted(() => { refreshTimer = window.setInterval(() => refresh(), 30000) })
|
||||
onUnmounted(() => { if (refreshTimer) window.clearInterval(refreshTimer) })
|
||||
|
||||
/**
|
||||
* 최근 댓글의 작성 시각을 짧게 표시한다.
|
||||
* @param {string} value - 작성 시각
|
||||
* @returns {string} 한국어 날짜·시각
|
||||
*/
|
||||
const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul'
|
||||
}).format(new Date(value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-comment-list border border-line bg-white px-4 py-3">
|
||||
<h2 class="admin-comment-list__heading mb-1 text-xs font-semibold text-muted">최근 댓글 <span class="font-normal">· 전체 {{ formatAnalyticsCount(data?.pagination.total) }}개</span></h2>
|
||||
<p v-if="error" class="admin-comment-list__error text-xs text-red-700" role="alert">
|
||||
댓글을 불러오지 못했습니다. <button type="button" class="underline" @click="refresh()">다시 시도</button>
|
||||
</p>
|
||||
<p v-else-if="!data?.items.length" class="admin-comment-list__empty py-1 text-xs text-muted">작성한 댓글이 없습니다.</p>
|
||||
<ul v-else class="admin-comment-list__items divide-y divide-line">
|
||||
<li v-for="comment in data.items" :key="comment.id" class="admin-comment-list__item grid min-w-0 grid-cols-2 items-center gap-x-3 gap-y-0.5 py-1.5 text-xs md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_7rem_8rem]">
|
||||
<NuxtLink :to="`/post/${encodeURIComponent(comment.postSlug)}#comments`" v-if="['published', 'members'].includes(comment.postStatus)" target="_blank" rel="noopener noreferrer" class="admin-comment-list__post truncate font-medium hover:underline" :title="comment.postTitle">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
|
||||
<NuxtLink v-else :to="`/admin/posts/${comment.postId}`" class="admin-comment-list__post truncate font-medium hover:underline" :title="comment.postTitle">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
|
||||
<p class="admin-comment-list__body truncate text-muted" :title="comment.body">{{ comment.body }}</p>
|
||||
<NuxtLink :to="`/admin/members/${comment.userId}`" class="admin-comment-list__author truncate text-muted hover:underline" :title="comment.username">{{ comment.username }}</NuxtLink>
|
||||
<time :datetime="comment.createdAt" class="admin-comment-list__time whitespace-nowrap text-muted">{{ formatCreatedAt(comment.createdAt) }}</time>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
99
components/admin/AdminConfirmModal.vue
Normal file
99
components/admin/AdminConfirmModal.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: '확인'
|
||||
},
|
||||
cancelLabel: {
|
||||
type: String,
|
||||
default: '취소'
|
||||
},
|
||||
confirmTone: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loadingLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
|
||||
/**
|
||||
* 확인 버튼 스타일 클래스를 반환한다.
|
||||
* @param {string} tone - 버튼 톤
|
||||
* @returns {string} 클래스 문자열
|
||||
*/
|
||||
const getConfirmButtonClass = (tone) => {
|
||||
if (tone === 'danger') {
|
||||
return 'bg-[#d21a26] text-white hover:bg-[#b81620]'
|
||||
}
|
||||
return 'bg-[#15171a] text-white hover:bg-[#2b2f35]'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="admin-confirm-modal fixed inset-0 z-[100] flex items-start justify-center bg-black/35 px-4 pt-10"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="'admin-confirm-modal-title'"
|
||||
>
|
||||
<section class="admin-confirm-modal__panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
<header class="admin-confirm-modal__header flex items-center justify-between border-b border-line px-6 py-5">
|
||||
<h2 id="admin-confirm-modal-title" class="admin-confirm-modal__title text-xl font-semibold">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<button
|
||||
class="admin-confirm-modal__close grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]"
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
:disabled="loading"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="admin-confirm-modal__body grid gap-4 px-6 py-5 text-sm leading-6 text-[#657080]">
|
||||
<slot />
|
||||
</div>
|
||||
<footer class="admin-confirm-modal__footer flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||
<button
|
||||
class="admin-confirm-modal__cancel h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]"
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="admin-confirm-modal__confirm h-10 rounded-md px-4 text-sm font-semibold transition disabled:opacity-50"
|
||||
:class="getConfirmButtonClass(confirmTone)"
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ loading && loadingLabel ? loadingLabel : confirmLabel }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
851
components/admin/AdminEditorBlockPanel.vue
Normal file
851
components/admin/AdminEditorBlockPanel.vue
Normal file
@@ -0,0 +1,851 @@
|
||||
<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',
|
||||
'update-inline-color'
|
||||
])
|
||||
|
||||
/** @type {number} 단일 이미지 직접 너비 최소값 */
|
||||
const IMAGE_WIDTH_MIN = 120
|
||||
/** @type {number} 단일 이미지 직접 너비 최대값 */
|
||||
const IMAGE_WIDTH_MAX = 1600
|
||||
/** @type {Array<{ label: string, value: string, pixelWidth: number|null }>} 단일 이미지 대표 너비 옵션 */
|
||||
const IMAGE_WIDTH_PRESET_OPTIONS = [
|
||||
{ label: '전체 너비(자동)', value: 'auto', pixelWidth: null },
|
||||
{ label: '갤러리 2장 1칸(356px)', value: '356', pixelWidth: 356 },
|
||||
{ label: '갤러리 3장 1칸(235px)', value: '235', pixelWidth: 235 },
|
||||
{ label: '갤러리 3장 2칸(477px)', value: '477', pixelWidth: 477 },
|
||||
{ label: '직접 입력', value: 'custom', pixelWidth: null }
|
||||
]
|
||||
|
||||
/** @type {import('vue').Ref<Set<string>>} 직접 입력으로 열어둔 이미지 너비 상태 */
|
||||
const customImageWidthKeys = ref(new Set())
|
||||
/** @type {import('vue').Ref<Record<string, string>>} 직접 너비 입력 중인 원본 문자열 */
|
||||
const imageWidthDraftValues = ref({})
|
||||
|
||||
const backgroundLabels = {
|
||||
gray: '회색',
|
||||
blue: '파랑',
|
||||
green: '초록',
|
||||
yellow: '노랑',
|
||||
red: '빨강',
|
||||
purple: '보라'
|
||||
}
|
||||
|
||||
const backgroundSwatches = {
|
||||
...CALLOUT_BACKGROUND_SWATCHES
|
||||
}
|
||||
|
||||
const inlineColorSwatches = {
|
||||
gray: '#6b7280',
|
||||
blue: '#0055ff',
|
||||
green: '#16ae68',
|
||||
yellow: '#a16207',
|
||||
red: '#e60000',
|
||||
purple: '#8800ff'
|
||||
}
|
||||
|
||||
/** @type {Array<{ label: string, value: string }>} 코드 블록 언어 셀렉트 옵션 */
|
||||
const CODE_LANGUAGE_OPTIONS = [
|
||||
{ label: '일반 텍스트', value: '' },
|
||||
{ label: 'JavaScript', value: 'javascript' },
|
||||
{ label: 'HTML', value: 'html' },
|
||||
{ label: 'CSS', value: 'css' },
|
||||
{ label: 'Vue', value: 'vue' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: 'Bash', value: 'bash' },
|
||||
{ label: 'Markdown', value: 'markdown' },
|
||||
{ label: 'SQL', value: 'sql' }
|
||||
]
|
||||
|
||||
/** @type {number|null} 패널 내부 포인터 조작 상태 해제 타이머 */
|
||||
let panelPointerInsideTimer = null
|
||||
/** @type {boolean} 패널 내부 포인터 조작 중 여부 */
|
||||
let isPanelPointerInside = false
|
||||
|
||||
/**
|
||||
* 배경 라벨을 반환한다.
|
||||
* @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
|
||||
|
||||
/**
|
||||
* 인라인 색상 스와치를 반환한다.
|
||||
* @param {string} color - 색상 키
|
||||
* @returns {string} CSS 색상
|
||||
*/
|
||||
const getInlineColorSwatch = (color) => inlineColorSwatches[color] || inlineColorSwatches.gray
|
||||
|
||||
/**
|
||||
* 블록 종류 라벨
|
||||
* @returns {string}
|
||||
*/
|
||||
const panelTitle = computed(() => {
|
||||
if (!props.panel) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'gallery') {
|
||||
return '갤러리'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'inline-color') {
|
||||
return '텍스트 색상'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'html') {
|
||||
return 'HTML'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'link') {
|
||||
return '링크'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'iframe') {
|
||||
return 'iframe'
|
||||
}
|
||||
|
||||
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 === 'inline-color') {
|
||||
return '선택한 텍스트'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'html') {
|
||||
return '사용자 HTML'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'link') {
|
||||
return 'URL 카드'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'iframe') {
|
||||
return 'sandbox iframe'
|
||||
}
|
||||
|
||||
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 {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} image - 이미지 정보
|
||||
* @returns {string} 입력 표시 값
|
||||
*/
|
||||
const getImagePixelWidthInputValue = (imageIndex, image) => {
|
||||
const draftKey = getImageWidthStateKey(imageIndex, image)
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(imageWidthDraftValues.value, draftKey)) {
|
||||
return imageWidthDraftValues.value[draftKey]
|
||||
}
|
||||
|
||||
const width = Math.round(Number(image?.pixelWidth) || 0)
|
||||
return width ? String(width) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 너비 UI 상태 키를 반환한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} image - 이미지 정보
|
||||
* @returns {string} 상태 키
|
||||
*/
|
||||
const getImageWidthStateKey = (imageIndex, image) => `${imageIndex}:${String(image?.url || '')}`
|
||||
|
||||
/**
|
||||
* 이미지 너비 직접 입력 UI 상태를 저장한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} image - 이미지 정보
|
||||
* @param {boolean} enabled - 직접 입력 여부
|
||||
* @returns {void}
|
||||
*/
|
||||
const setImageWidthCustomState = (imageIndex, image, enabled) => {
|
||||
const nextKeys = new Set(customImageWidthKeys.value)
|
||||
const key = getImageWidthStateKey(imageIndex, image)
|
||||
|
||||
if (enabled) {
|
||||
nextKeys.add(key)
|
||||
} else {
|
||||
nextKeys.delete(key)
|
||||
}
|
||||
|
||||
customImageWidthKeys.value = nextKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 너비 입력 임시 값을 저장한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} image - 이미지 정보
|
||||
* @param {string} value - 입력 문자열
|
||||
* @returns {void}
|
||||
*/
|
||||
const setImageWidthDraftValue = (imageIndex, image, value) => {
|
||||
imageWidthDraftValues.value = {
|
||||
...imageWidthDraftValues.value,
|
||||
[getImageWidthStateKey(imageIndex, image)]: value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 너비 입력 임시 값을 지운다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} image - 이미지 정보
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearImageWidthDraftValue = (imageIndex, image) => {
|
||||
const key = getImageWidthStateKey(imageIndex, image)
|
||||
const nextValues = { ...imageWidthDraftValues.value }
|
||||
delete nextValues[key]
|
||||
imageWidthDraftValues.value = nextValues
|
||||
}
|
||||
|
||||
/**
|
||||
* 직접 입력 너비를 허용 범위로 정리한다.
|
||||
* @param {unknown} value - 입력 너비
|
||||
* @returns {number|null} 정리된 너비
|
||||
*/
|
||||
const normalizeImageWidthInput = (value) => {
|
||||
const width = Math.round(Number(value) || 0)
|
||||
|
||||
if (!width) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Math.min(Math.max(width, IMAGE_WIDTH_MIN), IMAGE_WIDTH_MAX)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 너비 대표 옵션 값을 반환한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} image - 이미지 정보
|
||||
* @returns {string} 셀렉트 값
|
||||
*/
|
||||
const getImageWidthPresetValue = (imageIndex, image) => {
|
||||
const width = Math.round(Number(image?.pixelWidth) || 0)
|
||||
|
||||
if (customImageWidthKeys.value.has(getImageWidthStateKey(imageIndex, image))) {
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
if (!width) {
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
return IMAGE_WIDTH_PRESET_OPTIONS.some((option) => option.pixelWidth === width)
|
||||
? String(width)
|
||||
: 'custom'
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 너비 대표 옵션 변경을 전달한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} image - 이미지 정보
|
||||
* @param {Event} event - 변경 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const emitImageWidthPresetChange = (imageIndex, image, event) => {
|
||||
const value = String(event.target?.value || 'auto')
|
||||
const preset = IMAGE_WIDTH_PRESET_OPTIONS.find((option) => option.value === value)
|
||||
|
||||
if (value === 'custom') {
|
||||
setImageWidthCustomState(imageIndex, image, true)
|
||||
setImageWidthDraftValue(imageIndex, image, String(Math.round(Number(image?.pixelWidth) || 0) || 356))
|
||||
emit('update-media-image', imageIndex, {
|
||||
pixelWidth: Math.round(Number(image?.pixelWidth) || 0) || 356
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setImageWidthCustomState(imageIndex, image, false)
|
||||
clearImageWidthDraftValue(imageIndex, image)
|
||||
emit('update-media-image', imageIndex, {
|
||||
pixelWidth: preset?.pixelWidth || null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 너비 입력 변경을 전달한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} image - 이미지 정보
|
||||
* @param {Event} event - 입력 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const emitImagePixelWidthChange = (imageIndex, image, event) => {
|
||||
const raw = String(event.target?.value || '').trim()
|
||||
const width = Math.round(Number(raw) || 0)
|
||||
|
||||
setImageWidthCustomState(imageIndex, image, true)
|
||||
setImageWidthDraftValue(imageIndex, image, raw)
|
||||
|
||||
if (!raw) {
|
||||
emit('update-media-image', imageIndex, {
|
||||
pixelWidth: null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (width < IMAGE_WIDTH_MIN || width > IMAGE_WIDTH_MAX) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('update-media-image', imageIndex, {
|
||||
pixelWidth: width
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 너비 입력 종료 시 허용 범위로 정리해 전달한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Object} image - 이미지 정보
|
||||
* @param {Event} event - 입력 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const commitImagePixelWidthChange = (imageIndex, image, event) => {
|
||||
const raw = String(event.target?.value || '').trim()
|
||||
const pixelWidth = normalizeImageWidthInput(raw)
|
||||
|
||||
setImageWidthCustomState(imageIndex, image, true)
|
||||
clearImageWidthDraftValue(imageIndex, image)
|
||||
emit('update-media-image', imageIndex, {
|
||||
pixelWidth
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 패널 내부 포인터 조작 중에는 포커스 이탈로 패널이 닫히지 않게 유지한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const markPanelPointerInside = () => {
|
||||
window.clearTimeout(panelPointerInsideTimer)
|
||||
isPanelPointerInside = true
|
||||
emit('panel-focus-in')
|
||||
panelPointerInsideTimer = window.setTimeout(() => {
|
||||
isPanelPointerInside = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
|
||||
* @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
|
||||
}
|
||||
|
||||
if (isPanelPointerInside) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('panel-focus-out')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(panelPointerInsideTimer)
|
||||
})
|
||||
</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"
|
||||
@pointerdown.capture="markPanelPointerInside"
|
||||
@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 === 'inline-color'">
|
||||
<div class="admin-editor-block-panel__inline-color-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="color in QUOTE_BACKGROUND_OPTIONS"
|
||||
:key="`inline-color-${color}`"
|
||||
class="flex items-center gap-2 rounded border border-[#dce0e5] bg-[#fafafa] px-3 py-2 text-left text-xs font-semibold text-[#657080] transition hover:bg-white"
|
||||
type="button"
|
||||
@click="emit('update-inline-color', color)"
|
||||
>
|
||||
<span
|
||||
class="size-5 shrink-0 rounded-full border border-black/5"
|
||||
:style="{ background: getInlineColorSwatch(color) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ getQuoteBackgroundLabel(color) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="['html', 'link', 'iframe'].includes(panel.kind)">
|
||||
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||
<span class="font-semibold text-[#394047]">
|
||||
{{ panel.kind === 'link' ? 'URL' : panel.kind === 'iframe' ? 'iframe 코드' : 'HTML 코드' }}
|
||||
</span>
|
||||
<textarea
|
||||
class="min-h-28 resize-y rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 font-mono text-xs leading-5 text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
|
||||
:value="panel.url"
|
||||
:placeholder="panel.kind === 'link' ? 'https://example.com' : panel.kind === 'iframe' ? '<iframe src="https://..."></iframe>' : '<table>...</table>'"
|
||||
@input="emit('update-embed-url', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="panel.kind === 'html'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
본문 HTML은 그대로 표시하되 script, iframe, 이벤트 속성 같은 실행성 코드는 제거합니다. iframe은 /iframe 블록을 사용합니다.
|
||||
</p>
|
||||
<p v-else-if="panel.kind === 'link'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
YouTube·X(트위터)·Mastodon URL은 임베드로, 그 외 외부 URL은 링크 카드로 표시합니다.
|
||||
</p>
|
||||
<p v-else class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
YouTube·YouTube Shorts, X(트위터) 게시물 URL과 외부 서비스 iframe 코드를 지원합니다. iframe은 src 등 허용된 속성만 사용합니다.
|
||||
</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="admin-editor-block-panel__callout-icon-toggle flex cursor-pointer items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<span class="admin-editor-block-panel__callout-icon-toggle-copy flex min-w-0 items-center gap-3">
|
||||
<span class="admin-editor-block-panel__callout-icon-toggle-label font-bold text-[#15171a]">
|
||||
아이콘 표시
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-editor-block-panel__callout-icon-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="아이콘 표시"
|
||||
:checked="panel.calloutEmojiEnabled"
|
||||
@change="emit('update-callout-options', { calloutEmojiEnabled: $event.target.checked })"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="admin-editor-block-panel__callout-icon-options grid gap-2 transition-opacity"
|
||||
:class="panel.calloutEmojiEnabled ? '' : 'opacity-45'"
|
||||
>
|
||||
<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:cursor-not-allowed"
|
||||
: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.calloutEmojiEnabled && panel.calloutEmoji === emoji ? 'border-[#15171a] bg-white' : panel.calloutEmojiEnabled ? 'border-[#dce0e5] bg-[#fafafa] hover:bg-white' : 'cursor-not-allowed border-[#dce0e5] bg-[#f4f5f6] text-[#a4adb8]'"
|
||||
type="button"
|
||||
:disabled="!panel.calloutEmojiEnabled"
|
||||
@mousedown.prevent
|
||||
@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-5">
|
||||
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||
<span class="font-semibold text-[#394047]">언어</span>
|
||||
<AdminSelectInput
|
||||
:model-value="panel.language"
|
||||
:options="CODE_LANGUAGE_OPTIONS"
|
||||
label="코드 블록 언어"
|
||||
root-class="admin-editor-block-panel__code-language-select"
|
||||
width-class="w-full"
|
||||
select-class="bg-[#eff1f2] border-[#d7dde2] text-[#15171a]"
|
||||
@update:model-value="emit('update-code-options', { language: $event })"
|
||||
/>
|
||||
</label>
|
||||
<label class="admin-editor-block-panel__code-line-number-toggle flex cursor-pointer items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<span class="admin-editor-block-panel__code-line-number-toggle-copy flex min-w-0 items-center gap-3">
|
||||
<span class="admin-editor-block-panel__code-line-number-toggle-label font-bold text-[#15171a]">
|
||||
줄번호 표시
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-editor-block-panel__code-line-number-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="줄번호 표시"
|
||||
:checked="panel.showLineNumbers"
|
||||
@change="emit('update-code-options', { showLineNumbers: $event.target.checked })"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</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>
|
||||
<label
|
||||
v-if="panel.kind === 'image'"
|
||||
class="grid gap-1 text-xs font-semibold text-[#394047]"
|
||||
>
|
||||
표시 너비
|
||||
<span class="relative block">
|
||||
<select
|
||||
class="w-full appearance-none rounded border border-[#d7dde2] bg-white px-3 py-2 pr-9 text-sm font-normal text-[#15171a] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
|
||||
:value="getImageWidthPresetValue(imageIndex, image)"
|
||||
@change="emitImageWidthPresetChange(imageIndex, image, $event)"
|
||||
>
|
||||
<option
|
||||
v-for="option in IMAGE_WIDTH_PRESET_OPTIONS"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
|
||||
</label>
|
||||
<label
|
||||
v-if="panel.kind === 'image' && getImageWidthPresetValue(imageIndex, image) === 'custom'"
|
||||
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="getImagePixelWidthInputValue(imageIndex, image)"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
:min="IMAGE_WIDTH_MIN"
|
||||
:max="IMAGE_WIDTH_MAX"
|
||||
step="10"
|
||||
placeholder="자동"
|
||||
@input="emitImagePixelWidthChange(imageIndex, image, $event)"
|
||||
@blur="commitImagePixelWidthChange(imageIndex, image, $event)"
|
||||
>
|
||||
</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>
|
||||
58
components/admin/AdminFilterPillGroup.vue
Normal file
58
components/admin/AdminFilterPillGroup.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 현재 선택 값 */
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean],
|
||||
default: ''
|
||||
},
|
||||
/** 필터 옵션 목록 */
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
/** 필터 그룹 접근성 라벨 */
|
||||
label: {
|
||||
type: String,
|
||||
default: '필터'
|
||||
},
|
||||
/** 그룹 루트에 추가할 화면별 클래스 */
|
||||
rootClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 버튼에 추가할 화면별 클래스 */
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/**
|
||||
* 필터 옵션이 선택 상태인지 확인한다.
|
||||
* @param {Object} option - 필터 옵션
|
||||
* @returns {boolean} 선택 상태
|
||||
*/
|
||||
const isSelected = (option) => option.value === props.modelValue
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-filter-pill-group flex flex-wrap gap-1.5" :class="rootClass" role="group" :aria-label="label">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="String(option.value)"
|
||||
class="admin-filter-pill-group__button rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="[
|
||||
isSelected(option) ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink',
|
||||
buttonClass
|
||||
]"
|
||||
type="button"
|
||||
:aria-pressed="isSelected(option)"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span v-if="option.count !== undefined" class="ml-1 opacity-70">{{ option.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
331
components/admin/AdminHomeCoverSettingsCard.vue
Normal file
331
components/admin/AdminHomeCoverSettingsCard.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<script setup>
|
||||
const homeCoverInputRef = ref(null)
|
||||
const homeCoverDarkInputRef = ref(null)
|
||||
|
||||
/**
|
||||
* 메인 화면 커버 설정 카드
|
||||
* @property {Object} form - 사이트 설정 폼 객체
|
||||
* @property {boolean} editing - 편집 모드 여부
|
||||
* @property {boolean} saving - 저장 중 여부
|
||||
* @property {boolean} uploadingLight - 라이트 커버 업로드 중 여부
|
||||
* @property {boolean} uploadingDark - 다크 커버 업로드 중 여부
|
||||
* @property {boolean} hasChanges - 변경 여부
|
||||
*/
|
||||
defineProps({
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
uploadingLight: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
uploadingDark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasChanges: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['begin', 'cancel', 'save', 'upload-cover', 'drop-cover', 'clear-cover'])
|
||||
|
||||
/**
|
||||
* 메인 화면 커버 파일 선택 창을 연다.
|
||||
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
||||
* @returns {void}
|
||||
*/
|
||||
const openHomeCoverFilePicker = (variant = 'light') => {
|
||||
if (variant === 'dark') {
|
||||
homeCoverDarkInputRef.value?.click()
|
||||
return
|
||||
}
|
||||
|
||||
homeCoverInputRef.value?.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-home-cover"
|
||||
class="admin-home-cover-settings-card admin-settings-screen__card admin-settings-screen__card--home-cover 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]"
|
||||
>
|
||||
홈 상단에 720px 너비 커버 이미지를 표시합니다. 라이트·다크 이미지를 각각 등록할 수 있고, 이미지·텍스트는 저장 버튼으로 함께 반영합니다.
|
||||
</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-home-cover-settings-card__readonly admin-settings-screen__home-cover-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
|
||||
>
|
||||
<div class="grid min-w-0 gap-6">
|
||||
<div class="admin-settings-screen__home-cover-mode grid min-w-0 gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-bold text-[#15171a]">
|
||||
라이트모드
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.homeCoverImageUrl"
|
||||
class="admin-settings-screen__home-cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||
>
|
||||
<HomeHero
|
||||
class="!max-w-full"
|
||||
:image-url="form.homeCoverImageUrl"
|
||||
:dark-image-url="''"
|
||||
:title="form.homeCoverTitle"
|
||||
:text="form.homeCoverText"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="admin-settings-screen__home-cover-empty grid aspect-[720/215] w-full max-w-full place-items-center rounded-lg border border-dashed border-[#cfd6de] bg-[#f7f8fa] px-4 text-center text-sm text-[#657080]"
|
||||
>
|
||||
라이트모드 이미지가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-settings-screen__home-cover-mode grid min-w-0 gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-bold text-[#15171a]">
|
||||
다크모드
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.homeCoverDarkImageUrl"
|
||||
class="admin-settings-screen__home-cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||
>
|
||||
<HomeHero
|
||||
class="!max-w-full"
|
||||
:image-url="form.homeCoverDarkImageUrl"
|
||||
:dark-image-url="''"
|
||||
:title="form.homeCoverTitle"
|
||||
:text="form.homeCoverText"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="admin-settings-screen__home-cover-empty grid aspect-[720/215] w-full max-w-full place-items-center rounded-lg border border-dashed border-[#cfd6de] bg-[#f7f8fa] px-4 text-center text-sm text-[#657080]"
|
||||
>
|
||||
다크모드 전용 이미지가 없습니다. 공개 화면에서는 라이트모드 이미지를 대신 사용합니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-home-cover-settings-card__edit admin-settings-screen__home-cover-edit grid min-w-0 gap-6 border-t border-[#eceff2] pt-5">
|
||||
<div class="grid min-w-0 gap-6">
|
||||
<div class="admin-settings-screen__home-cover-mode grid min-w-0 gap-2">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-bold text-[#15171a]">
|
||||
라이트모드
|
||||
</h3>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
기본 헤더 이미지입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingLight"
|
||||
@click="openHomeCoverFilePicker('light')"
|
||||
>
|
||||
{{ uploadingLight ? '업로드 중' : '이미지 변경' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="form.homeCoverImageUrl"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingLight"
|
||||
@click="$emit('clear-cover', 'light')"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.homeCoverImageUrl"
|
||||
class="admin-settings-screen__home-cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||
>
|
||||
<HomeHero
|
||||
class="!max-w-full"
|
||||
:image-url="form.homeCoverImageUrl"
|
||||
:dark-image-url="''"
|
||||
:title="form.homeCoverTitle"
|
||||
:text="form.homeCoverText"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="admin-settings-screen__home-cover-dropzone grid aspect-[720/215] w-full max-w-full cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-[#f7f8fa] px-4 text-center transition hover:border-[#15171a] hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="uploadingLight"
|
||||
@click="openHomeCoverFilePicker('light')"
|
||||
@dragover.prevent
|
||||
@drop.prevent="$emit('drop-cover', $event, 'light')"
|
||||
>
|
||||
<span class="grid place-items-center gap-2 text-sm font-semibold text-[#657080]">
|
||||
<svg class="size-6 text-[#8a94a3]" xmlns="http://www.w3.org/2000/svg" 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 16V4" />
|
||||
<path d="m7 9 5-5 5 5" />
|
||||
<path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />
|
||||
</svg>
|
||||
<span>라이트모드 이미지를 드롭하거나 선택하세요.</span>
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref="homeCoverInputRef"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:disabled="uploadingLight"
|
||||
@change="$emit('upload-cover', $event, 'light')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="admin-settings-screen__home-cover-mode grid min-w-0 gap-2">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-bold text-[#15171a]">
|
||||
다크모드
|
||||
</h3>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
다크모드에서 교체되는 이미지입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingDark"
|
||||
@click="openHomeCoverFilePicker('dark')"
|
||||
>
|
||||
{{ uploadingDark ? '업로드 중' : '이미지 변경' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="form.homeCoverDarkImageUrl"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingDark"
|
||||
@click="$emit('clear-cover', 'dark')"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.homeCoverDarkImageUrl"
|
||||
class="admin-settings-screen__home-cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||
>
|
||||
<HomeHero
|
||||
class="!max-w-full"
|
||||
:image-url="form.homeCoverDarkImageUrl"
|
||||
:dark-image-url="''"
|
||||
:title="form.homeCoverTitle"
|
||||
:text="form.homeCoverText"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="admin-settings-screen__home-cover-dropzone grid aspect-[720/215] w-full max-w-full cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-[#f7f8fa] px-4 text-center transition hover:border-[#15171a] hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="uploadingDark"
|
||||
@click="openHomeCoverFilePicker('dark')"
|
||||
@dragover.prevent
|
||||
@drop.prevent="$emit('drop-cover', $event, 'dark')"
|
||||
>
|
||||
<span class="grid place-items-center gap-2 text-sm font-semibold text-[#657080]">
|
||||
<svg class="size-6 text-[#8a94a3]" xmlns="http://www.w3.org/2000/svg" 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 16V4" />
|
||||
<path d="m7 9 5-5 5 5" />
|
||||
<path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />
|
||||
</svg>
|
||||
<span>다크모드 이미지를 드롭하거나 선택하세요.</span>
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref="homeCoverDarkInputRef"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:disabled="uploadingDark"
|
||||
@change="$emit('upload-cover', $event, 'dark')"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">오버레이 제목</span>
|
||||
<input
|
||||
v-model="form.homeCoverTitle"
|
||||
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
placeholder="선택 사항"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">오버레이 본문</span>
|
||||
<textarea
|
||||
v-model="form.homeCoverText"
|
||||
class="min-h-[4.5rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
maxlength="280"
|
||||
rows="3"
|
||||
placeholder="짧은 소개 문구 (선택 사항)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
1393
components/admin/AdminMarkdownEditor.vue
Normal file
1393
components/admin/AdminMarkdownEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
362
components/admin/AdminMarkdownMediaPickerModal.vue
Normal file
362
components/admin/AdminMarkdownMediaPickerModal.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<script setup>
|
||||
import {
|
||||
getMediaItemKind,
|
||||
getMediaPickerPreviewUrl
|
||||
} from '../../lib/markdown-editor-media.js'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
activeTab: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
searchInput: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
mediaItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedUrls: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isUploading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
uploadHint: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
rangeLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
pageCount: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
pageItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
applyState: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
disabled: true,
|
||||
label: '삽입'
|
||||
})
|
||||
},
|
||||
getSelectionOrder: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'apply',
|
||||
'close',
|
||||
'drop',
|
||||
'image-error',
|
||||
'page',
|
||||
'search-composition-end',
|
||||
'search-input',
|
||||
'tab',
|
||||
'toggle',
|
||||
'upload'
|
||||
])
|
||||
|
||||
/**
|
||||
* 탭을 변경한다.
|
||||
* @param {string} tab - 변경할 탭
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectTab = (tab) => {
|
||||
emit('tab', tab)
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 입력 변경을 부모로 전달한다.
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleFileChange = (event) => {
|
||||
emit('upload', event.target.files)
|
||||
event.target.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-markdown-media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="emit('close')">
|
||||
<div class="admin-markdown-media-picker__panel flex h-[min(86vh,760px)] min-h-[620px] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
|
||||
<header class="admin-markdown-media-picker__header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">
|
||||
<div>
|
||||
<h2 class="admin-markdown-media-picker__title text-lg font-bold text-black">
|
||||
{{ title }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
class="admin-markdown-media-picker__close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isUploading"
|
||||
@click="emit('close')"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-markdown-media-picker__tabs flex items-center gap-3 border-b border-[#e3e6e8] px-5">
|
||||
<div class="flex min-w-0 flex-1 items-center">
|
||||
<button
|
||||
class="admin-markdown-media-picker__tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="selectTab('upload')"
|
||||
>
|
||||
업로드
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-media-picker__tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="selectTab('library')"
|
||||
>
|
||||
미디어 라이브러리
|
||||
</button>
|
||||
</div>
|
||||
<label v-if="activeTab === 'library'" class="admin-markdown-media-picker__search mb-px flex w-full max-w-xs shrink-0 items-center py-2">
|
||||
<span class="sr-only">파일명 검색</span>
|
||||
<input
|
||||
:value="searchInput"
|
||||
class="admin-markdown-media-picker__search-input w-full rounded border border-[#d7dde2] bg-white px-3 py-1.5 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]/20"
|
||||
type="search"
|
||||
placeholder="파일명 검색"
|
||||
autocomplete="off"
|
||||
@input="emit('search-input', $event)"
|
||||
@compositionupdate="emit('search-input', $event)"
|
||||
@compositionend="emit('search-composition-end', $event)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-markdown-media-picker__body min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<template v-if="activeTab === 'library'">
|
||||
<div v-if="isLoading" class="admin-markdown-media-picker__loading py-12 text-center text-sm text-[#8e9cac]">
|
||||
불러오는 중
|
||||
</div>
|
||||
<div v-else-if="mediaItems.length" class="admin-markdown-media-picker__grid grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button
|
||||
v-for="item in mediaItems"
|
||||
:key="item.url"
|
||||
class="admin-markdown-media-picker__item group relative overflow-hidden rounded border bg-white text-left transition"
|
||||
:class="selectedUrls.includes(item.url) ? 'border-[3px] border-[#ff7a00] ring-2 ring-[#ff7a00]/40' : 'border border-[#e3e6e8] hover:border-[#8e9cac]'"
|
||||
type="button"
|
||||
@click="emit('toggle', item)"
|
||||
>
|
||||
<span
|
||||
v-if="props.getSelectionOrder(item.url)"
|
||||
class="admin-markdown-media-picker__selection-order absolute right-2 top-2 z-10 grid size-7 place-items-center rounded-full bg-[#ff7a00] text-xs font-bold text-white shadow-sm ring-2 ring-white"
|
||||
>
|
||||
{{ props.getSelectionOrder(item.url) }}
|
||||
</span>
|
||||
<img
|
||||
v-if="getMediaItemKind(item) === 'image'"
|
||||
class="admin-markdown-media-picker__thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
|
||||
:src="getMediaPickerPreviewUrl(item)"
|
||||
:alt="item.name || ''"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="emit('image-error', { event: $event, item })"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="admin-markdown-media-picker__thumb flex aspect-[4/3] w-full items-center justify-center bg-[#f6f7f8] text-xs font-bold uppercase tracking-[0.18em] text-[#6b7280]"
|
||||
>
|
||||
{{ getMediaItemKind(item) }}
|
||||
</span>
|
||||
<span class="admin-markdown-media-picker__name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
|
||||
{{ item.name || item.url }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="admin-markdown-media-picker__empty py-12 text-center text-sm text-[#8e9cac]">
|
||||
{{ emptyMessage }}
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="admin-markdown-media-picker__upload-zone relative grid h-full min-h-[420px] place-items-center overflow-hidden rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
|
||||
:class="isUploading ? 'admin-markdown-media-picker__upload-zone--uploading select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
|
||||
@dragover.prevent
|
||||
@drop.prevent="emit('drop', $event)"
|
||||
>
|
||||
<div class="admin-markdown-media-picker__upload-inner grid gap-3 px-6">
|
||||
<p class="admin-markdown-media-picker__upload-title text-lg font-semibold text-[#15171a]">
|
||||
{{ isUploading ? '업로드 중입니다' : '파일을 끌어 업로드' }}
|
||||
</p>
|
||||
<p class="admin-markdown-media-picker__upload-or text-sm text-[#6b7280]">
|
||||
{{ isUploading ? '잠시만 기다려 주세요.' : '또는' }}
|
||||
</p>
|
||||
<label
|
||||
class="admin-markdown-media-picker__upload-button mx-auto inline-flex h-10 items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50"
|
||||
:class="isUploading ? 'cursor-not-allowed border-[#c8ced3] text-[#8e9cac] hover:bg-transparent' : 'cursor-pointer'"
|
||||
>
|
||||
{{ isUploading ? '업로드 중' : '파일 선택' }}
|
||||
<input
|
||||
class="sr-only"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
:disabled="isUploading"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
</label>
|
||||
<p class="admin-markdown-media-picker__upload-hint text-xs text-[#8e9cac]">
|
||||
{{ uploadHint }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="isUploading"
|
||||
class="admin-markdown-media-picker__upload-overlay pointer-events-none absolute inset-0 grid place-items-center bg-white/72 backdrop-blur-[1px]"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="admin-markdown-media-picker__upload-loading grid w-full max-w-sm gap-4 px-8 text-center">
|
||||
<span class="admin-markdown-media-picker__upload-spinner mx-auto size-8 rounded-full border-2 border-[#d7dde2] border-t-[#15171a]" aria-hidden="true" />
|
||||
<strong class="text-sm font-semibold text-[#15171a]">업로드 중입니다</strong>
|
||||
<span class="admin-markdown-media-picker__upload-skeleton h-2 overflow-hidden rounded-full bg-[#e3e6e8]" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer v-if="activeTab === 'library'" class="admin-markdown-media-picker__footer flex flex-wrap items-center justify-between gap-3 border-t border-[#e3e6e8] px-5 py-4">
|
||||
<div class="admin-markdown-media-picker__pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-[#6b7280]">
|
||||
<span>{{ rangeLabel }}</span>
|
||||
<nav v-if="pageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="미디어 선택 페이지">
|
||||
<button
|
||||
class="admin-markdown-media-picker__page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isLoading || page <= 1"
|
||||
aria-label="이전 페이지"
|
||||
@click="emit('page', page - 1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<template v-for="item in pageItems" :key="item">
|
||||
<span v-if="typeof item === 'string'" class="px-1 text-[#8e9cac]">...</span>
|
||||
<button
|
||||
v-else
|
||||
class="admin-markdown-media-picker__page-button grid size-8 place-items-center rounded-full text-xs font-semibold transition"
|
||||
:class="item === page ? 'bg-[#15171a] text-white' : 'text-[#8e9cac] hover:bg-[#f1f3f5] hover:text-[#394047]'"
|
||||
type="button"
|
||||
:disabled="isLoading"
|
||||
@click="emit('page', item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="admin-markdown-media-picker__page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isLoading || page >= pageCount"
|
||||
aria-label="다음 페이지"
|
||||
@click="emit('page', page + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="admin-markdown-media-picker__actions flex items-center justify-end gap-2">
|
||||
<button
|
||||
class="admin-markdown-media-picker__cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isUploading"
|
||||
@click="emit('close')"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-media-picker__apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="applyState.disabled"
|
||||
@click="emit('apply')"
|
||||
>
|
||||
{{ applyState.label }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-markdown-media-picker__upload-zone--uploading::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
background: linear-gradient(110deg, transparent 0%, rgba(255, 255, 255, 0.72) 45%, transparent 72%);
|
||||
transform: translateX(-100%);
|
||||
animation: admin-markdown-media-picker-upload-sheen 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.admin-markdown-media-picker__upload-spinner {
|
||||
animation: admin-markdown-media-picker-upload-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.admin-markdown-media-picker__upload-skeleton::before {
|
||||
display: block;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.86), transparent);
|
||||
animation: admin-markdown-media-picker-upload-skeleton 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes admin-markdown-media-picker-upload-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes admin-markdown-media-picker-upload-sheen {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes admin-markdown-media-picker-upload-skeleton {
|
||||
from {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(260%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
60
components/admin/AdminMarkdownModeToggle.vue
Normal file
60
components/admin/AdminMarkdownModeToggle.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
activeMode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
teleportTo: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="teleportTo" :to="teleportTo">
|
||||
<button
|
||||
class="admin-markdown-editor__mode-toggle inline-flex size-8 shrink-0 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8e9cac]"
|
||||
type="button"
|
||||
:aria-label="activeMode === 'write' ? '미리보기로 전환' : '작성 모드로 전환'"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<svg
|
||||
v-if="activeMode === 'write'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="admin-markdown-editor__mode-icon lucide-book-open"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 7v14" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="admin-markdown-editor__mode-icon lucide-edit-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 21h8" />
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Teleport>
|
||||
</template>
|
||||
116
components/admin/AdminMarkdownPreviewSurface.vue
Normal file
116
components/admin/AdminMarkdownPreviewSurface.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
slashMenuActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
slashSuppressedLines: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'keydown',
|
||||
'gallery-reorder',
|
||||
'merge-images-to-gallery',
|
||||
'insert-image-to-gallery',
|
||||
'extract-gallery-image',
|
||||
'remove-gallery-image',
|
||||
'block-content-change',
|
||||
'content-replace',
|
||||
'append-paragraph',
|
||||
'insert-after-line',
|
||||
'delete-line',
|
||||
'merge-with-previous-line',
|
||||
'line-focus',
|
||||
'line-blur',
|
||||
'slash-update',
|
||||
'slash-end',
|
||||
'slash-apply'
|
||||
])
|
||||
|
||||
const previewRef = ref(null)
|
||||
const rendererRef = ref(null)
|
||||
|
||||
/**
|
||||
* 미리보기 표면 DOM을 반환한다.
|
||||
* @returns {HTMLElement|null} 미리보기 DOM
|
||||
*/
|
||||
const getPreviewElement = () => previewRef.value
|
||||
|
||||
/**
|
||||
* 지정한 라이브 편집 줄에 포커스를 이동한다.
|
||||
* @param {...unknown} args - ContentMarkdownRenderer 포커스 인자
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusEditableAtLine = (...args) => {
|
||||
rendererRef.value?.focusEditableAtLine?.(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 문서 전체를 선택한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectAllLiveDocument = () => {
|
||||
rendererRef.value?.selectAllLiveDocument?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 줄 번호에 해당하는 라이브 편집 DOM을 반환한다.
|
||||
* @param {number|string|null} sourceLine - 원본 줄 번호
|
||||
* @returns {Element|null} 줄 DOM
|
||||
*/
|
||||
const getSourceLineElement = (sourceLine) => {
|
||||
if (sourceLine === null || sourceLine === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return previewRef.value?.querySelector?.(`[data-source-line="${sourceLine}"]`) ?? null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getPreviewElement,
|
||||
focusEditableAtLine,
|
||||
selectAllLiveDocument,
|
||||
getSourceLineElement
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="previewRef"
|
||||
class="admin-markdown-editor__preview min-h-[620px] px-0 py-5 text-[#15171a] outline-none"
|
||||
style="--site-text: #15171a; --site-muted: #6b7280; --site-panel: #f6f7f8; --site-line: #e3e6e8; --site-accent: #2eb6ea;"
|
||||
tabindex="0"
|
||||
@keydown.capture="emit('keydown', $event)"
|
||||
>
|
||||
<ContentMarkdownRenderer
|
||||
ref="rendererRef"
|
||||
:content="props.content"
|
||||
interactive
|
||||
:slash-menu-active="props.slashMenuActive"
|
||||
:slash-suppressed-lines="props.slashSuppressedLines"
|
||||
@gallery-reorder="emit('gallery-reorder', $event)"
|
||||
@merge-images-to-gallery="emit('merge-images-to-gallery', $event)"
|
||||
@insert-image-to-gallery="emit('insert-image-to-gallery', $event)"
|
||||
@extract-gallery-image="emit('extract-gallery-image', $event)"
|
||||
@remove-gallery-image="emit('remove-gallery-image', $event)"
|
||||
@block-content-change="emit('block-content-change', $event)"
|
||||
@content-replace="emit('content-replace', $event)"
|
||||
@append-paragraph="emit('append-paragraph', $event)"
|
||||
@insert-after-line="emit('insert-after-line', $event)"
|
||||
@delete-line="emit('delete-line', $event)"
|
||||
@merge-with-previous-line="emit('merge-with-previous-line', $event)"
|
||||
@line-focus="emit('line-focus', $event)"
|
||||
@line-blur="emit('line-blur', $event)"
|
||||
@slash-update="emit('slash-update', $event)"
|
||||
@slash-end="emit('slash-end', $event)"
|
||||
@slash-apply="emit('slash-apply', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
71
components/admin/AdminMarkdownSlashMenu.vue
Normal file
71
components/admin/AdminMarkdownSlashMenu.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
|
||||
|
||||
defineProps({
|
||||
/** @type {import('vue').PropType<Array<{ id: string, label: string }>>} */
|
||||
commands: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
highlightedIndex: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/** @type {import('vue').PropType<{ top: number, left: number }>} */
|
||||
position: {
|
||||
type: Object,
|
||||
default: () => ({ top: 0, left: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['highlight', 'select'])
|
||||
const rootRef = ref(null)
|
||||
|
||||
/**
|
||||
* 부모 컴포넌트의 위치 보정·스크롤 계산에 사용할 메뉴 루트 엘리먼트를 반환한다.
|
||||
* @returns {HTMLElement|null} 메뉴 루트 엘리먼트
|
||||
*/
|
||||
const getRootElement = () => rootRef.value
|
||||
|
||||
defineExpose({
|
||||
getRootElement,
|
||||
rootRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="admin-markdown-editor__slash-menu fixed z-[60] w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded-lg border border-[#e3e6e8] bg-white p-1 shadow-lg"
|
||||
:style="{ top: `${position.top}px`, left: `${position.left}px` }"
|
||||
role="listbox"
|
||||
aria-label="슬래시 명령"
|
||||
>
|
||||
<button
|
||||
v-for="(command, commandIndex) in commands"
|
||||
:key="`live-slash-${command.id}`"
|
||||
class="admin-markdown-editor__slash-item flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left transition-colors"
|
||||
:class="commandIndex === highlightedIndex ? 'admin-markdown-editor__slash-item--active bg-[#f4f5f6]' : ''"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="commandIndex === highlightedIndex"
|
||||
@mouseenter="emit('highlight', commandIndex)"
|
||||
@mousedown.prevent="emit('select', command)"
|
||||
>
|
||||
<span class="admin-markdown-editor__slash-leading flex min-w-0 items-center gap-2.5">
|
||||
<AdminSlashCommandIcon
|
||||
:command-id="command.id"
|
||||
class="admin-markdown-editor__slash-icon shrink-0 text-[#394047]"
|
||||
/>
|
||||
<span class="admin-markdown-editor__slash-label truncate text-sm text-[#15171a]">{{ command.label }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="admin-markdown-editor__slash-slug shrink-0 text-xs text-[#8b959e] transition-opacity"
|
||||
:class="commandIndex === highlightedIndex ? 'opacity-100' : 'opacity-0'"
|
||||
>/{{ command.id }}</span>
|
||||
</button>
|
||||
<p v-if="!commands.length" class="admin-markdown-editor__slash-empty px-4 py-3 text-sm text-[#6b7280]">
|
||||
일치하는 명령이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
5
components/admin/AdminMarkdownUploadingBadge.vue
Normal file
5
components/admin/AdminMarkdownUploadingBadge.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="admin-markdown-editor__uploading absolute right-3 top-3 rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white">
|
||||
업로드 중
|
||||
</div>
|
||||
</template>
|
||||
120
components/admin/AdminMarkdownWriteSurface.vue
Normal file
120
components/admin/AdminMarkdownWriteSurface.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import AdminMarkdownUploadingBadge from './AdminMarkdownUploadingBadge.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
gutterLineCount: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
getGutterLineHeight: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isUploading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'keydown',
|
||||
'paste',
|
||||
'drop',
|
||||
'input',
|
||||
'compositionstart',
|
||||
'compositionend',
|
||||
'click',
|
||||
'keyup',
|
||||
'select',
|
||||
'focus',
|
||||
'blur'
|
||||
])
|
||||
|
||||
const textareaRef = ref(null)
|
||||
const gutterRef = ref(null)
|
||||
|
||||
/**
|
||||
* textarea 입력 값을 부모 모델에 반영하고 입력 이벤트를 전달한다.
|
||||
* @param {Event} event - textarea 입력 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onInput = (event) => {
|
||||
emit('update:modelValue', event.target?.value || '')
|
||||
emit('input', event)
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성 textarea DOM을 반환한다.
|
||||
* @returns {HTMLTextAreaElement|null} textarea DOM
|
||||
*/
|
||||
const getTextareaElement = () => textareaRef.value
|
||||
|
||||
/**
|
||||
* 줄 번호 gutter DOM을 반환한다.
|
||||
* @returns {HTMLElement|null} gutter DOM
|
||||
*/
|
||||
const getGutterElement = () => gutterRef.value
|
||||
|
||||
defineExpose({
|
||||
getTextareaElement,
|
||||
getGutterElement
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-markdown-editor__write relative">
|
||||
<div class="admin-markdown-editor__editor-surface min-h-[620px]">
|
||||
<div class="admin-markdown-editor__write-columns flex items-start">
|
||||
<div
|
||||
ref="gutterRef"
|
||||
class="admin-markdown-editor__gutter w-10 shrink-0 select-none py-5 pr-2 font-mono text-[13px] leading-7 text-[#a0a8b0]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="ln in props.gutterLineCount"
|
||||
:key="`gutter-line-${ln}`"
|
||||
class="admin-markdown-editor__gutter-line text-right tabular-nums"
|
||||
:style="{ height: `${props.getGutterLineHeight(ln - 1)}px` }"
|
||||
>
|
||||
{{ ln }}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
:value="modelValue"
|
||||
class="admin-markdown-editor__textarea min-h-[620px] min-w-0 flex-1 resize-none overflow-hidden break-words border-0 bg-transparent py-5 pl-0 pr-5 text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
|
||||
placeholder="마크다운으로 글을 작성하세요."
|
||||
spellcheck="false"
|
||||
@keydown="emit('keydown', $event)"
|
||||
@paste="emit('paste', $event)"
|
||||
@drop="emit('drop', $event)"
|
||||
@dragover.prevent
|
||||
@input="onInput"
|
||||
@compositionstart="emit('compositionstart', $event)"
|
||||
@compositionend="emit('compositionend', $event)"
|
||||
@click="emit('click', $event)"
|
||||
@keyup="emit('keyup', $event)"
|
||||
@select="emit('select', $event)"
|
||||
@focus="emit('focus', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AdminMarkdownUploadingBadge v-if="isUploading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-markdown-editor__gutter {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.admin-markdown-editor__gutter::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
424
components/admin/AdminMediaDetailModal.vue
Normal file
424
components/admin/AdminMediaDetailModal.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
canEditMediaCategory: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
canRegeneratePageCoverThumbnail: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
canRegeneratePostBodyThumbnail: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
canRegeneratePostThumbnail: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
canRenameMediaItem: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
deletingUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
editingCategory: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
editingName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
folders: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
getMediaBadgeLabel: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaItemKind: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaPreviewUrl: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getPostBodyThumbnailResolutionLabel: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isMediaItemLocked: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isPageCoverThumbnailDiskItem: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isPostBodyThumbnailDiskItem: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isPostCardThumbnailDiskItem: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
mediaThumbnailRoot: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
regeneratingBodyThumbnailUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
regeneratingThumbnailUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'close',
|
||||
'delete',
|
||||
'download',
|
||||
'regenerate-body-thumbnail',
|
||||
'regenerate-page-cover-thumbnail',
|
||||
'regenerate-post-thumbnail',
|
||||
'rename',
|
||||
'save-category',
|
||||
'update:editingCategory',
|
||||
'update:editingName'
|
||||
])
|
||||
|
||||
/**
|
||||
* 파일 용량을 사람이 읽을 수 있는 문자열로 변환한다.
|
||||
* @param {number} size - 바이트 단위 파일 크기
|
||||
* @returns {string} 표시용 파일 크기
|
||||
*/
|
||||
const formatFileSize = (size) => {
|
||||
if (!size) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const exponent = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1)
|
||||
const value = size / Math.pow(1024, exponent)
|
||||
|
||||
return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 시각을 짧은 로캘 문자열로 표시한다.
|
||||
* @param {string | null} iso - ISO 시각
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatDateTime = (iso) => {
|
||||
if (!iso) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
try {
|
||||
return new Date(iso).toLocaleString('ko-KR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-media-detail-modal admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<section class="admin-media__modal-panel grid max-h-[86vh] w-full max-w-5xl overflow-hidden bg-white text-ink shadow-xl lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div class="admin-media__preview grid min-h-[20rem] place-items-center bg-[#f5f5f2] p-5">
|
||||
<img
|
||||
v-if="props.getMediaItemKind(props.item) === 'image'"
|
||||
class="admin-media__preview-image max-h-[72vh] max-w-full object-contain"
|
||||
:src="props.getMediaPreviewUrl(props.item)"
|
||||
:alt="props.item.title"
|
||||
>
|
||||
<video
|
||||
v-else-if="props.getMediaItemKind(props.item) === 'video'"
|
||||
class="admin-media__preview-image max-h-[72vh] max-w-full bg-black"
|
||||
:src="props.item.url"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="props.getMediaItemKind(props.item) === 'audio'"
|
||||
class="w-full max-w-xl"
|
||||
:src="props.item.url"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="rounded border border-line bg-white px-5 py-4 text-sm font-semibold text-muted"
|
||||
>
|
||||
{{ props.item.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
|
||||
<div class="admin-media__detail-header flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Attachment
|
||||
</p>
|
||||
<h2 class="admin-media__detail-title mt-1 break-all text-xl font-semibold">
|
||||
{{ props.item.name }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="admin-media__detail-header-actions flex shrink-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="admin-media__download rounded border border-line px-3 py-1.5 text-sm font-semibold text-ink hover:bg-surface"
|
||||
type="button"
|
||||
@click="emit('download')"
|
||||
>
|
||||
다운로드
|
||||
</button>
|
||||
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="emit('close')">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="admin-media__info grid gap-3 text-sm">
|
||||
<div class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">경로</dt>
|
||||
<dd class="admin-media__info-value mt-1 break-all">{{ props.item.url }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
|
||||
<dd class="admin-media__info-value mt-1">{{ formatFileSize(props.item.size) }}</dd>
|
||||
</div>
|
||||
<div v-if="props.item.thumbnailStatus" class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">
|
||||
{{ props.item.thumbnailStatus.variant === 'pageCover' || props.item.thumbnailStatus.role === 'pageCover' ? '페이지 커버 이미지' : '대표 이미지' }}
|
||||
</dt>
|
||||
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
|
||||
<span v-if="props.item.thumbnailStatus.role === 'card'">
|
||||
자동 생성된 목록 카드용 썸네일입니다.
|
||||
</span>
|
||||
<span v-else-if="props.item.thumbnailStatus.role === 'pageCover'">
|
||||
자동 생성된 Pages 진열장용 커버 썸네일입니다.
|
||||
</span>
|
||||
<template v-else>
|
||||
<span v-if="props.item.thumbnailStatus.hasThumbnail">
|
||||
생성됨: {{ props.item.thumbnailStatus.thumbnailUrl }}
|
||||
</span>
|
||||
<span v-else-if="props.item.thumbnailStatus.isFallbackActive && props.item.thumbnailStatus.variant === 'pageCover'" class="font-semibold text-amber-700">
|
||||
페이지 커버 썸네일 없음. 현재 Pages 진열장에서 원본 이미지를 불러옵니다.
|
||||
</span>
|
||||
<span v-else-if="props.item.thumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
|
||||
썸네일 없음. 현재 목록에서 원본 이미지를 불러옵니다.
|
||||
</span>
|
||||
<span v-else-if="props.item.thumbnailStatus.variant === 'pageCover'">
|
||||
페이지 커버 썸네일 없음. 홈 Pages 진열장에서 사용되면 원본으로 대체됩니다.
|
||||
</span>
|
||||
<span v-else>
|
||||
썸네일 없음. 대표 이미지로 사용되면 원본으로 대체됩니다.
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="props.item.thumbnailStatus.originalUrl" class="text-muted">
|
||||
원본: {{ props.item.thumbnailStatus.originalUrl }}
|
||||
</span>
|
||||
<button
|
||||
v-if="props.canRegeneratePostThumbnail(props.item)"
|
||||
class="admin-media__thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="props.regeneratingThumbnailUrl === props.item.url"
|
||||
@click="emit('regenerate-post-thumbnail', props.item)"
|
||||
>
|
||||
{{ props.regeneratingThumbnailUrl === props.item.url ? '생성 중' : (props.item.thumbnailStatus.hasThumbnail ? '대표 이미지 다시 생성' : '대표 이미지 생성') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="props.canRegeneratePageCoverThumbnail(props.item)"
|
||||
class="admin-media__page-cover-thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="props.regeneratingThumbnailUrl === props.item.url"
|
||||
@click="emit('regenerate-page-cover-thumbnail', props.item)"
|
||||
>
|
||||
{{ props.regeneratingThumbnailUrl === props.item.url ? '생성 중' : (props.item.thumbnailStatus.hasThumbnail ? '페이지 커버 썸네일 다시 생성' : '페이지 커버 썸네일 생성') }}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="props.item.bodyThumbnailStatus" class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">본문 이미지</dt>
|
||||
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
|
||||
<span v-if="props.item.bodyThumbnailStatus.role === 'body'">
|
||||
자동 생성된 본문 표시용 WebP 이미지입니다. ({{ props.getPostBodyThumbnailResolutionLabel(props.item) }})
|
||||
</span>
|
||||
<template v-else>
|
||||
<span v-if="props.item.bodyThumbnailStatus.hasThumbnail">
|
||||
생성됨: {{ props.item.bodyThumbnailStatus.thumbnailUrl }}
|
||||
</span>
|
||||
<span v-else-if="props.item.bodyThumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
|
||||
본문 이미지 없음. 현재 본문에서 원본 이미지를 불러옵니다.
|
||||
</span>
|
||||
<span v-else>
|
||||
본문 이미지 없음. 게시물 본문에서 사용되면 원본으로 대체됩니다.
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="props.item.bodyThumbnailStatus.originalUrl" class="text-muted">
|
||||
원본: {{ props.item.bodyThumbnailStatus.originalUrl }}
|
||||
</span>
|
||||
<button
|
||||
v-if="props.canRegeneratePostBodyThumbnail(props.item)"
|
||||
class="admin-media__body-thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="props.regeneratingBodyThumbnailUrl === props.item.url"
|
||||
@click="emit('regenerate-body-thumbnail', props.item)"
|
||||
>
|
||||
{{ props.regeneratingBodyThumbnailUrl === props.item.url ? '생성 중' : (props.item.bodyThumbnailStatus.hasThumbnail ? '본문 이미지 다시 생성' : '본문 이미지 생성') }}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="admin-media__category grid gap-2">
|
||||
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
|
||||
폴더
|
||||
</label>
|
||||
<div class="admin-media__category-row flex gap-2">
|
||||
<input
|
||||
id="media-category"
|
||||
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-surface disabled:opacity-70"
|
||||
type="text"
|
||||
list="media-folder-options"
|
||||
placeholder="미분류"
|
||||
:disabled="!props.canEditMediaCategory(props.item)"
|
||||
:value="props.editingCategory"
|
||||
@input="emit('update:editingCategory', $event.target.value)"
|
||||
@keydown.enter.prevent="emit('save-category')"
|
||||
>
|
||||
<button
|
||||
class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="!props.canEditMediaCategory(props.item)"
|
||||
@click="emit('save-category')"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="props.item.avatarOwner" class="admin-media__category-hint text-xs text-muted">
|
||||
프로필 썸네일의 논리 폴더는 「{{ props.mediaThumbnailRoot }}」로 고정됩니다.
|
||||
</p>
|
||||
<p v-else-if="props.isPostCardThumbnailDiskItem(props.item)" class="admin-media__category-hint text-xs text-muted">
|
||||
대표 이미지 파일은 원본 이미지에 연결된 자동 생성 파일이라 폴더를 직접 바꾸지 않습니다.
|
||||
</p>
|
||||
<datalist id="media-folder-options">
|
||||
<option v-for="folder in props.folders" :key="folder" :value="folder" />
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.item.avatarOwner"
|
||||
class="admin-media__avatar-owner rounded border border-line bg-surface p-3 text-xs"
|
||||
>
|
||||
<strong class="admin-media__avatar-owner-title text-ink">연결된 회원</strong>
|
||||
<dl class="admin-media__avatar-owner-fields mt-2 grid gap-2 text-muted">
|
||||
<div class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">닉네임</dt>
|
||||
<dd class="mt-0.5 font-semibold text-ink">{{ props.item.avatarOwner.username }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">이메일</dt>
|
||||
<dd class="mt-0.5 break-all text-ink">{{ props.item.avatarOwner.email }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">등급</dt>
|
||||
<dd class="mt-0.5 text-ink">{{ props.getMediaBadgeLabel(props.item) }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 접속</dt>
|
||||
<dd class="mt-0.5 text-ink">{{ formatDateTime(props.item.avatarOwner.lastSeenAt) }}</dd>
|
||||
</div>
|
||||
<div v-if="props.item.avatarOwner.lastSeenIp" class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 IP</dt>
|
||||
<dd class="mt-0.5 break-all font-mono text-[11px] text-ink">{{ props.item.avatarOwner.lastSeenIp }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||
<strong class="admin-media__usage-title text-ink">
|
||||
사용 현황 {{ props.item.usage.length }}곳
|
||||
</strong>
|
||||
<ul v-if="props.item.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
|
||||
<li v-for="usage in props.item.usage" :key="`${props.item.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
|
||||
<NuxtLink
|
||||
v-if="usage.adminUrl"
|
||||
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
|
||||
:to="usage.adminUrl"
|
||||
>
|
||||
{{ usage.title }}
|
||||
</NuxtLink>
|
||||
<span v-else class="admin-media__usage-name font-semibold text-ink">{{ usage.title }}</span>
|
||||
<span class="admin-media__usage-meta"> · {{ usage.typeLabel }} · {{ usage.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="admin-media__usage-empty mt-2 text-muted">
|
||||
{{ props.item.avatarOwner ? '프로필 연결 외에 다른 사용처는 없습니다.' : '사용 중인 곳이 없습니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__rename grid gap-2">
|
||||
<label class="admin-media__rename-label text-xs font-semibold text-muted" for="media-name">
|
||||
파일명
|
||||
</label>
|
||||
<input
|
||||
id="media-name"
|
||||
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
|
||||
type="text"
|
||||
:disabled="!props.canRenameMediaItem(props.item)"
|
||||
:placeholder="props.item.title"
|
||||
:value="props.editingName"
|
||||
@input="emit('update:editingName', $event.target.value)"
|
||||
@keydown.enter.prevent="emit('rename')"
|
||||
>
|
||||
<p v-if="props.isMediaItemLocked(props.item)" class="admin-media__locked text-xs text-muted">
|
||||
게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 잠깁니다.
|
||||
</p>
|
||||
<p v-else-if="props.isPostCardThumbnailDiskItem(props.item) || props.isPageCoverThumbnailDiskItem(props.item) || props.isPostBodyThumbnailDiskItem(props.item)" class="admin-media__locked text-xs text-muted">
|
||||
자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다. 사용처가 없는 파생 파일만 정리용으로 삭제할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__actions flex flex-wrap gap-2">
|
||||
<button
|
||||
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="!props.canRenameMediaItem(props.item) || !props.editingName"
|
||||
@click="emit('rename')"
|
||||
>
|
||||
파일명 저장
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="props.deletingUrl === props.item.url || props.isMediaItemLocked(props.item)"
|
||||
@click="emit('delete', props.item)"
|
||||
>
|
||||
{{ props.deletingUrl === props.item.url ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
243
components/admin/AdminMediaFolderPanel.vue
Normal file
243
components/admin/AdminMediaFolderPanel.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup>
|
||||
const openMenuId = defineModel('openMenuId', {
|
||||
type: String,
|
||||
default: ''
|
||||
})
|
||||
|
||||
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
|
||||
const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
||||
|
||||
const props = defineProps({
|
||||
activeFolder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
activeSystemMediaCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
activeSystemMediaHint: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
activeSystemMediaTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
folderMediaCounts: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
folders: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
libraryCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'library'
|
||||
},
|
||||
deletingFolder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
draggingCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'create-folder',
|
||||
'drop-folder',
|
||||
'remove-folder',
|
||||
'select-folder'
|
||||
])
|
||||
|
||||
const hoveredFolder = ref(null)
|
||||
|
||||
/**
|
||||
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @returns {boolean} 썸네일 전용이면 true
|
||||
*/
|
||||
const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || String(folder).startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)
|
||||
|
||||
/**
|
||||
* 폴더 행 메뉴 id를 반환한다.
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @returns {string} 행 메뉴 id
|
||||
*/
|
||||
const getFolderMenuId = (folder) => `folder:${folder}`
|
||||
|
||||
/**
|
||||
* 폴더 경로의 표시 이름을 반환한다.
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @returns {string} 표시 이름
|
||||
*/
|
||||
const getFolderName = (folder) => String(folder || '').split('/').filter(Boolean).pop() || '미분류'
|
||||
|
||||
/**
|
||||
* 폴더 깊이를 반환한다.
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @returns {number} 폴더 깊이
|
||||
*/
|
||||
const getFolderDepth = (folder) => Math.max(String(folder || '').split('/').filter(Boolean).length - 1, 0)
|
||||
|
||||
/**
|
||||
* 내부 미디어 드래그만 폴더 이동 대상으로 허용한다.
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {string} folder - 대상 폴더
|
||||
* @returns {void}
|
||||
*/
|
||||
const onFolderDragOver = (event, folder) => {
|
||||
if (!props.draggingCount || props.busy || isThumbnailFolderPath(folder) || (props.deletingFolder && props.deletingFolder === folder)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
hoveredFolder.value = folder
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더 내부 요소 간 이동을 제외하고 드롭 강조를 해제한다.
|
||||
* @param {DragEvent} event - 드래그 이탈 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onFolderDragLeave = (event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
hoveredFolder.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 내부 드래그에 한해 폴더 이동을 요청한다.
|
||||
* @param {DragEvent} event - 드롭 이벤트
|
||||
* @param {string} folder - 대상 폴더
|
||||
* @returns {void}
|
||||
*/
|
||||
const onFolderDrop = (event, folder) => {
|
||||
event.preventDefault()
|
||||
hoveredFolder.value = null
|
||||
if (props.draggingCount && !props.busy && !isThumbnailFolderPath(folder) && props.deletingFolder !== folder) {
|
||||
emit('drop-folder', folder)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.draggingCount, (count) => {
|
||||
if (!count) {
|
||||
hoveredFolder.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
v-if="props.mode === 'library'"
|
||||
class="admin-media-folder-panel admin-media__folders rounded border border-line bg-white p-3"
|
||||
>
|
||||
<button
|
||||
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface"
|
||||
:class="hoveredFolder === '' ? 'bg-sky-50 text-sky-900 ring-2 ring-inset ring-sky-500 hover:bg-sky-50' : !props.activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
|
||||
type="button"
|
||||
:disabled="props.busy"
|
||||
@click="emit('select-folder', '')"
|
||||
@dragenter="onFolderDragOver($event, '')"
|
||||
@dragover="onFolderDragOver($event, '')"
|
||||
@dragleave="onFolderDragLeave"
|
||||
@drop="onFolderDrop($event, '미분류')"
|
||||
>
|
||||
<span>
|
||||
전체 미디어
|
||||
<span v-if="hoveredFolder === ''" class="admin-media__folder-drop-hint mt-1 block text-xs font-semibold">놓으면 {{ props.draggingCount }}개를 미분류로 이동</span>
|
||||
</span>
|
||||
<span>{{ props.libraryCount }}</span>
|
||||
</button>
|
||||
|
||||
<div class="admin-media__folder-list mt-3 grid gap-1">
|
||||
<div
|
||||
v-for="folder in props.folders"
|
||||
:key="folder"
|
||||
class="admin-media__folder-row flex items-stretch rounded transition-colors"
|
||||
:class="hoveredFolder === folder ? 'admin-media__folder-row--drop-target bg-sky-50 text-sky-900 ring-2 ring-inset ring-sky-500' : props.activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
|
||||
@dragenter="onFolderDragOver($event, folder)"
|
||||
@dragover="onFolderDragOver($event, folder)"
|
||||
@dragleave="onFolderDragLeave"
|
||||
@drop="onFolderDrop($event, folder)"
|
||||
>
|
||||
<button
|
||||
class="admin-media__folder-button flex min-w-0 flex-1 items-center justify-between rounded py-2 pr-3 text-left text-sm"
|
||||
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
|
||||
type="button"
|
||||
:disabled="props.busy"
|
||||
:title="folder"
|
||||
:aria-current="props.activeFolder === folder ? 'true' : undefined"
|
||||
@click="emit('select-folder', folder)"
|
||||
>
|
||||
<span class="admin-media__folder-label min-w-0">
|
||||
<span class="admin-media__folder-name block truncate">{{ getFolderName(folder) }}</span>
|
||||
<span v-if="hoveredFolder === folder" class="admin-media__folder-drop-hint mt-1 block text-xs font-semibold">놓으면 {{ props.draggingCount }}개 이동</span>
|
||||
</span>
|
||||
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ props.folderMediaCounts[folder] || 0 }}</span>
|
||||
</button>
|
||||
<AdminRowMoreMenu
|
||||
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
|
||||
v-model:open-menu-id="openMenuId"
|
||||
:item-id="getFolderMenuId(folder)"
|
||||
menu-label="폴더 메뉴"
|
||||
size="sm"
|
||||
class="mr-0.5 shrink-0"
|
||||
:inverse="props.activeFolder === folder && hoveredFolder !== folder"
|
||||
:busy="props.busy || props.deletingFolder === folder"
|
||||
>
|
||||
<button
|
||||
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:disabled="props.busy || props.deletingFolder === folder"
|
||||
@click="emit('remove-folder', folder)"
|
||||
>
|
||||
폴더 삭제
|
||||
</button>
|
||||
</AdminRowMoreMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__folder-actions mt-4 border-t border-line pt-4">
|
||||
<button
|
||||
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
|
||||
type="button"
|
||||
:disabled="props.busy"
|
||||
@click="emit('create-folder')"
|
||||
>
|
||||
폴더 추가
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
v-else
|
||||
class="admin-media-folder-panel admin-media__folders admin-media__folders--thumbnails rounded border border-line bg-white p-3"
|
||||
>
|
||||
<button
|
||||
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold bg-[#15171a] text-white hover:bg-[#15171a]"
|
||||
type="button"
|
||||
@click="emit('select-folder', '')"
|
||||
>
|
||||
<span>{{ props.activeSystemMediaTitle }}</span>
|
||||
<span>{{ props.activeSystemMediaCount }}</span>
|
||||
</button>
|
||||
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
|
||||
{{ props.activeSystemMediaHint }}
|
||||
</p>
|
||||
</aside>
|
||||
</template>
|
||||
154
components/admin/AdminMediaGrid.vue
Normal file
154
components/admin/AdminMediaGrid.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: 'library'
|
||||
},
|
||||
getMediaBadgeClass: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaBadgeLabel: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaGridPreviewUrl: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaItemKind: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaUsageBadgeCount: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedUrls: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
draggingUrls: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'drag-start',
|
||||
'drag-end',
|
||||
'image-error',
|
||||
'open-detail',
|
||||
'toggle-selection'
|
||||
])
|
||||
|
||||
/**
|
||||
* 미디어 선택 여부를 확인한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 선택 여부
|
||||
*/
|
||||
const isMediaSelected = (item) => props.selectedUrls.includes(item?.url)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-media-grid admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
<div
|
||||
v-for="(item, index) in props.items"
|
||||
:key="item.url"
|
||||
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
|
||||
:class="[
|
||||
isMediaSelected(item) ? 'admin-media__item--selected' : '',
|
||||
props.draggingUrls.includes(item.url) ? 'admin-media__item--dragging opacity-40' : '',
|
||||
props.disabled ? 'opacity-60' : props.activeTab === 'library' ? 'cursor-grab active:cursor-grabbing' : ''
|
||||
]"
|
||||
:draggable="props.activeTab === 'library' && !props.disabled"
|
||||
@dragstart="emit('drag-start', $event, item)"
|
||||
@dragend="emit('drag-end')"
|
||||
>
|
||||
<span v-if="isMediaSelected(item)" class="admin-media__selection-outline pointer-events-none absolute inset-0 z-20 border-2 border-sky-600" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="admin-media__select-toggle absolute left-1.5 top-1.5 z-10 grid size-7 place-items-center rounded-md border-2 shadow-md outline-none transition focus-visible:ring-2 focus-visible:ring-[#15171a] focus-visible:ring-offset-1"
|
||||
:class="isMediaSelected(item)
|
||||
? 'border-white bg-[#15171a] text-white'
|
||||
: 'border-[#394047] bg-white/95 text-[#15171a] hover:border-[#15171a] hover:bg-white'"
|
||||
:aria-label="`${item.name} 선택`"
|
||||
:aria-pressed="isMediaSelected(item)"
|
||||
:disabled="props.disabled"
|
||||
@click.stop="emit('toggle-selection', item, index, $event)"
|
||||
>
|
||||
<svg
|
||||
v-if="isMediaSelected(item)"
|
||||
class="size-4 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__thumb flex w-full flex-col text-left outline-none"
|
||||
type="button"
|
||||
:disabled="props.disabled"
|
||||
@click="emit('open-detail', item)"
|
||||
>
|
||||
<span class="admin-media__thumb-frame relative block w-full overflow-hidden">
|
||||
<img
|
||||
v-if="props.getMediaItemKind(item) === 'image'"
|
||||
class="admin-media__image aspect-square w-full bg-surface object-cover"
|
||||
:src="props.getMediaGridPreviewUrl(item)"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
:draggable="false"
|
||||
@error="emit('image-error', $event, item)"
|
||||
>
|
||||
<AdminMediaVideoThumbnail
|
||||
v-else-if="props.getMediaItemKind(item) === 'video'"
|
||||
:src="item.url"
|
||||
:alt="item.title"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"
|
||||
>
|
||||
{{ props.getMediaItemKind(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="props.getMediaUsageBadgeCount(item)"
|
||||
class="admin-media__usage-count-badge pointer-events-none absolute right-1.5 top-1.5 z-[1] rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{{ props.getMediaUsageBadgeCount(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="props.getMediaBadgeLabel(item)"
|
||||
class="admin-media__usage-badge pointer-events-none absolute right-1.5 z-[1] rounded px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="[
|
||||
props.getMediaBadgeClass(item),
|
||||
props.getMediaUsageBadgeCount(item) ? 'top-8' : 'top-1.5'
|
||||
]"
|
||||
>
|
||||
{{ props.getMediaBadgeLabel(item) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
108
components/admin/AdminMediaVideoThumbnail.vue
Normal file
108
components/admin/AdminMediaVideoThumbnail.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<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"
|
||||
:draggable="false"
|
||||
>
|
||||
<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>
|
||||
82
components/admin/AdminMemberActivityList.vue
Normal file
82
components/admin/AdminMemberActivityList.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
|
||||
const props = defineProps({ memberId: { type: String, required: true } })
|
||||
const searchInput = ref('')
|
||||
const filters = ref({ q: '', type: 'all', page: 1 })
|
||||
const query = computed(() => ({ ...filters.value, pageSize: 10 }))
|
||||
const { data, status, error, refresh } = await useFetch(
|
||||
() => `/admin/api/members/${props.memberId}/activity`,
|
||||
{ query, default: () => ({ items: [], pagination: { page: 1, pageCount: 1, total: 0 } }) }
|
||||
)
|
||||
|
||||
/**
|
||||
* 검색어를 적용하고 첫 페이지를 조회한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applySearch = () => { filters.value = { ...filters.value, q: searchInput.value.trim(), page: 1 } }
|
||||
|
||||
/**
|
||||
* 작성 종류를 바꾸고 첫 페이지에서 다시 조회한다.
|
||||
* @param {Event} event - 종류 선택 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const changeType = (event) => { filters.value = { q: searchInput.value.trim(), type: event.target.value, page: 1 } }
|
||||
|
||||
/**
|
||||
* 통합 검색 결과에서 선택한 페이지를 조회한다.
|
||||
* @param {number} page - 페이지 번호
|
||||
* @returns {void}
|
||||
*/
|
||||
const changePage = (page) => { filters.value = { ...filters.value, page } }
|
||||
|
||||
/**
|
||||
* 작성 시각을 짧은 한국어 날짜로 표시한다.
|
||||
* @param {string} value - 작성 시각
|
||||
* @returns {string} 날짜·시각
|
||||
*/
|
||||
const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul'
|
||||
}).format(new Date(value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-member-activity border border-line bg-white p-4" :aria-busy="status === 'pending'">
|
||||
<div class="admin-member-activity__toolbar flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="admin-member-activity__heading text-sm font-semibold">작성한 글 <span class="text-xs font-normal text-muted">{{ formatAnalyticsCount(data?.pagination.total) }}개 · 최신순</span></h2>
|
||||
<form class="admin-member-activity__search flex w-full flex-wrap items-center gap-2 sm:w-auto" role="search" @submit.prevent="applySearch">
|
||||
<select class="admin-member-activity__type h-10 rounded border border-line bg-white px-3 text-sm" aria-label="작성 종류" :value="filters.type" @change="changeType">
|
||||
<option value="all">전체</option>
|
||||
<option value="comment">댓글</option>
|
||||
<option value="post">게시글</option>
|
||||
</select>
|
||||
<AdminSearchInput root-class="min-w-0 flex-1" width-class="w-full sm:w-64" :value="searchInput" label="작성한 글 검색" placeholder="게시글 제목·내용 검색" @input="searchInput = $event.target.value" />
|
||||
<button type="submit" class="admin-member-activity__search-button h-10 shrink-0 rounded border border-line px-3 text-sm">검색</button>
|
||||
</form>
|
||||
</div>
|
||||
<p v-if="error" class="admin-member-activity__error py-6 text-sm text-red-700" role="alert">목록을 불러오지 못했습니다. <button type="button" class="underline" @click="refresh()">다시 시도</button></p>
|
||||
<p v-else-if="status === 'pending'" class="admin-member-activity__loading py-6 text-sm text-muted">목록을 불러오는 중입니다.</p>
|
||||
<p v-else-if="!data?.items.length" class="admin-member-activity__empty py-6 text-sm text-muted">{{ filters.q ? '검색 결과가 없습니다.' : '작성한 글이 없습니다.' }}</p>
|
||||
<div v-else class="admin-member-activity__table-wrap mt-3 overflow-x-auto">
|
||||
<table class="admin-member-activity__table w-full min-w-[600px] table-fixed text-left text-xs">
|
||||
<caption class="sr-only">회원이 작성한 게시글과 댓글 목록</caption>
|
||||
<thead class="border-b border-line text-muted">
|
||||
<tr>
|
||||
<th scope="col" class="w-[32%] py-2 pr-3 font-medium">게시글</th>
|
||||
<th scope="col" class="py-2 pr-3 font-medium">내용</th>
|
||||
<th scope="col" class="w-44 py-2 font-medium">작성일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line">
|
||||
<tr v-for="item in data.items" :key="`${item.kind}-${item.id}`" class="admin-member-activity__item">
|
||||
<td class="py-2 pr-3">
|
||||
<NuxtLink :to="item.publicUrl || `/admin/posts/${item.postId}`" :target="item.publicUrl ? '_blank' : undefined" :rel="item.publicUrl ? 'noopener noreferrer' : undefined" class="admin-member-activity__post block truncate font-medium hover:underline" :title="item.postTitle">{{ item.postTitle || '(제목 없음)' }}</NuxtLink>
|
||||
</td>
|
||||
<td class="py-2 pr-3"><p class="admin-member-activity__body truncate text-muted" :title="item.body">{{ item.body || '—' }}</p></td>
|
||||
<td class="whitespace-nowrap py-2 text-muted"><time :datetime="item.createdAt">{{ formatCreatedAt(item.createdAt) }}</time></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<AdminPagination v-if="status !== 'pending' && !error" :page="data?.pagination.page" :page-count="data?.pagination.pageCount" label="작성한 글 페이지" @update:page="changePage" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -12,25 +12,48 @@ 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 selectedMemberTab = ref('info')
|
||||
const memberTabs = [
|
||||
{ value: 'info', label: '정보' },
|
||||
{ value: 'posts', label: '작성한 글' }
|
||||
]
|
||||
|
||||
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 googleUnlinkModalOpen = ref(false)
|
||||
const isUpdatingPassword = ref(false)
|
||||
const isDeletingMember = ref(false)
|
||||
const actionMessage = ref('')
|
||||
const isUnlinkingGoogle = ref(false)
|
||||
const actionError = ref('')
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'owner', label: '소유자' },
|
||||
{ value: 'admin', label: '관리자' },
|
||||
{ value: 'vip', label: 'VIP' },
|
||||
{ value: 'member', label: '멤버' }
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
avatarUrl: '',
|
||||
roleCode: 'member',
|
||||
labelsText: '',
|
||||
note: ''
|
||||
})
|
||||
@@ -44,6 +67,10 @@ const deleteForm = reactive({
|
||||
confirmText: ''
|
||||
})
|
||||
|
||||
/** 소유자 권한 이전 확인 모달 */
|
||||
const ownershipTransferModalOpen = ref(false)
|
||||
const isTransferringOwnership = ref(false)
|
||||
|
||||
/**
|
||||
* 회원 폼 값을 현재 회원 정보로 동기화한다.
|
||||
* @returns {void}
|
||||
@@ -53,6 +80,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 +105,133 @@ const normalizedLabels = computed(() => [...new Set(
|
||||
.filter(Boolean)
|
||||
)])
|
||||
|
||||
const isFormEditable = computed(() => {
|
||||
if (isNewMember.value) {
|
||||
return true
|
||||
}
|
||||
if (!canEditMemberProfile.value) {
|
||||
return false
|
||||
}
|
||||
return 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 isCurrentAdminOwner = computed(() => currentAdminRoleCode.value === 'owner')
|
||||
const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId)
|
||||
&& String(props.member.id) === String(adminSession.value.userId))
|
||||
/** 저장된 대상 회원 등급(권한 판단용) */
|
||||
const targetSavedRoleCode = computed(() => String(props.member?.roleCode || 'member'))
|
||||
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(targetSavedRoleCode.value))
|
||||
|
||||
/**
|
||||
* 대상 회원 프로필을 수정할 수 있는지 여부
|
||||
* @returns {boolean} 수정 가능 여부
|
||||
*/
|
||||
const canEditMemberProfile = computed(() => {
|
||||
if (isNewMember.value) {
|
||||
return isCurrentAdminPrivileged.value
|
||||
}
|
||||
if (currentAdminRoleCode.value === 'owner') {
|
||||
return true
|
||||
}
|
||||
if (currentAdminRoleCode.value === 'admin') {
|
||||
return !isEditingSelf.value && ['vip', 'member'].includes(targetSavedRoleCode.value)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const canOpenEditMode = computed(() => !isNewMember.value && canEditMemberProfile.value && !isEditingMember.value)
|
||||
const canManageMemberActions = computed(() => canEditMemberProfile.value)
|
||||
const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value)
|
||||
const googleLinked = computed(() => Boolean(props.member?.googleLinkedAt))
|
||||
const googleAccountText = computed(() => props.member?.googleEmail || props.member?.googleName || 'Google 계정')
|
||||
const canUnlinkGoogle = computed(() => !isNewMember.value && googleLinked.value && isCurrentAdminOwner.value)
|
||||
|
||||
/**
|
||||
* 회원 등급 변경 권한 여부(저장 중 상태는 제외)
|
||||
* @returns {boolean} 변경 가능 여부
|
||||
*/
|
||||
const canChangeMemberRole = computed(() => {
|
||||
if (!isFormEditable.value || shouldRenderRoleAsText.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'owner') {
|
||||
return !isEditingSelf.value
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin') {
|
||||
return !isTargetPrivilegedRole.value
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const canEditRoleSelect = computed(() => canChangeMemberRole.value && !isSaving.value)
|
||||
const availableRoleOptions = computed(() => {
|
||||
if (!canEditRoleSelect.value) {
|
||||
return roleOptions
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin') {
|
||||
return roleOptions.filter((option) => ['vip', 'member'].includes(option.value))
|
||||
}
|
||||
|
||||
return roleOptions
|
||||
})
|
||||
|
||||
/**
|
||||
* 저장 시 소유자 권한 이전 확인이 필요한지 여부
|
||||
* @returns {boolean} 확인 필요 여부
|
||||
*/
|
||||
const isOwnershipTransferPending = computed(() => {
|
||||
return !isNewMember.value
|
||||
&& currentAdminRoleCode.value === 'owner'
|
||||
&& !isEditingSelf.value
|
||||
&& form.roleCode === 'owner'
|
||||
&& targetSavedRoleCode.value !== 'owner'
|
||||
})
|
||||
const roleHelpText = computed(() => {
|
||||
if (!isFormEditable.value) {
|
||||
return '수정하기를 누르면 변경 가능한 항목을 편집할 수 있습니다.'
|
||||
}
|
||||
|
||||
if (shouldRenderRoleAsText.value) {
|
||||
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
|
||||
}
|
||||
|
||||
if (!canEditMemberProfile.value && currentAdminRoleCode.value === 'admin') {
|
||||
return '관리자는 VIP·멤버 정보만 수정할 수 있습니다.'
|
||||
}
|
||||
|
||||
if (isEditingSelf.value && currentAdminRoleCode.value === 'owner') {
|
||||
return '소유자는 본인 권한을 직접 낮출 수 없습니다.'
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin' && isTargetPrivilegedRole.value) {
|
||||
return '관리자는 소유자·관리자 정보를 수정하거나 등급을 변경할 수 없습니다.'
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin') {
|
||||
return '관리자는 멤버와 VIP 등급만 변경할 수 있습니다.'
|
||||
}
|
||||
|
||||
if (isOwnershipTransferPending.value) {
|
||||
return '소유자 등급으로 저장하면 권한 이전 확인 모달이 표시됩니다. 승인 시 본인은 관리자로 변경됩니다.'
|
||||
}
|
||||
|
||||
return 'VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.'
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 저장 요청 본문을 문자열로 직렬화한다.
|
||||
* @returns {string} 직렬화된 회원 입력값
|
||||
*/
|
||||
const serializeMemberPayload = () => JSON.stringify(getMemberPayload())
|
||||
const serializeMemberPayload = () => JSON.stringify({
|
||||
...getMemberPayload(),
|
||||
roleCode: form.roleCode
|
||||
})
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
@@ -157,18 +307,35 @@ 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 = () => {
|
||||
if (!canEditMemberProfile.value) {
|
||||
return
|
||||
}
|
||||
isEditingMember.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openAvatarFilePicker = () => {
|
||||
if (!isFormEditable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
avatarInputRef.value?.click()
|
||||
}
|
||||
|
||||
@@ -185,20 +352,26 @@ 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', {
|
||||
formData.append('file', file)
|
||||
const result = await $fetch('/admin/api/member-avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.avatarUrl = result.files?.[0]?.url || ''
|
||||
form.avatarUrl = result.avatarUrl || ''
|
||||
|
||||
if (!isNewMember.value) {
|
||||
showToast('info', '썸네일 미리보기가 반영되었습니다. 저장을 눌러 적용하세요.')
|
||||
}
|
||||
} catch (error) {
|
||||
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.')
|
||||
} finally {
|
||||
isUploadingAvatar.value = false
|
||||
if (target) {
|
||||
@@ -212,6 +385,10 @@ const uploadAvatar = async (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeAvatar = () => {
|
||||
if (!isFormEditable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
form.avatarUrl = ''
|
||||
}
|
||||
|
||||
@@ -238,7 +415,6 @@ const closeActionMenu = () => {
|
||||
const openPasswordModal = () => {
|
||||
passwordForm.password = ''
|
||||
passwordForm.passwordConfirm = ''
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
passwordModalOpen.value = true
|
||||
closeActionMenu()
|
||||
@@ -250,7 +426,6 @@ const openPasswordModal = () => {
|
||||
*/
|
||||
const openDeleteModal = () => {
|
||||
deleteForm.confirmText = ''
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
deleteModalOpen.value = true
|
||||
closeActionMenu()
|
||||
@@ -280,12 +455,36 @@ const closeDeleteModal = () => {
|
||||
deleteModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 연동 해제 확인 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openGoogleUnlinkModal = () => {
|
||||
if (!canUnlinkGoogle.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionError.value = ''
|
||||
googleUnlinkModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 연동 해제 확인 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeGoogleUnlinkModal = () => {
|
||||
if (isUnlinkingGoogle.value) {
|
||||
return
|
||||
}
|
||||
|
||||
googleUnlinkModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한으로 회원 비밀번호를 변경한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const updateMemberPassword = async () => {
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
|
||||
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
|
||||
@@ -309,9 +508,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 +523,6 @@ const updateMemberPassword = async () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteMember = async () => {
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
|
||||
if (deleteForm.confirmText !== form.email) {
|
||||
@@ -337,12 +537,113 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한으로 회원 Google 연동을 해제한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const unlinkGoogleIdentity = async () => {
|
||||
if (!canUnlinkGoogle.value || isUnlinkingGoogle.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionError.value = ''
|
||||
isUnlinkingGoogle.value = true
|
||||
|
||||
try {
|
||||
const updated = await $fetch(`/admin/api/members/${props.member.id}/google`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
googleUnlinkModalOpen.value = false
|
||||
emit('saved', updated)
|
||||
showToast('success', 'Google 연동을 해제했습니다.')
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || 'Google 연동 해제에 실패했습니다.'
|
||||
actionError.value = message
|
||||
showToast('error', message)
|
||||
} finally {
|
||||
isUnlinkingGoogle.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 기본 정보를 저장한다.
|
||||
* @returns {Promise<Object>} 저장된 회원
|
||||
*/
|
||||
const performMemberSave = async () => {
|
||||
const payload = getMemberPayload()
|
||||
const roleChanged = !isNewMember.value && form.roleCode !== props.member?.roleCode
|
||||
|
||||
if (roleChanged && canChangeMemberRole.value && form.roleCode !== 'owner') {
|
||||
await $fetch(`/admin/api/members/${props.member.id}/role`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
role: form.roleCode
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saved = isNewMember.value
|
||||
? await $fetch('/admin/api/members', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
})
|
||||
: await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||
method: 'PUT',
|
||||
body: payload
|
||||
})
|
||||
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
emit('saved', saved)
|
||||
isEditingMember.value = isNewMember.value
|
||||
return saved
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한 이전 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeOwnershipTransferModal = () => {
|
||||
if (isTransferringOwnership.value) {
|
||||
return
|
||||
}
|
||||
ownershipTransferModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한 이전을 확정한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const confirmOwnershipTransfer = async () => {
|
||||
if (isTransferringOwnership.value || !props.member?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
isTransferringOwnership.value = true
|
||||
try {
|
||||
if (hasUnsavedMemberChanges.value) {
|
||||
await performMemberSave()
|
||||
}
|
||||
await $fetch(`/admin/api/members/${props.member.id}/ownership-transfer`, {
|
||||
method: 'POST'
|
||||
})
|
||||
ownershipTransferModalOpen.value = false
|
||||
showToast('success', '소유자 권한을 이전했습니다. 본인 계정은 관리자로 변경되었습니다.')
|
||||
await navigateTo('/admin/members')
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '소유자 권한 이전에 실패했습니다.')
|
||||
} finally {
|
||||
isTransferringOwnership.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 기본 정보를 저장한다.
|
||||
* @returns {Promise<void>}
|
||||
@@ -352,27 +653,30 @@ const saveMember = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
saveMessage.value = ''
|
||||
saveError.value = ''
|
||||
if (!isFormEditable.value) {
|
||||
if (!canOpenEditMode.value) {
|
||||
return
|
||||
}
|
||||
enterEditMode()
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasUnsavedMemberChanges.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isOwnershipTransferPending.value) {
|
||||
ownershipTransferModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const payload = getMemberPayload()
|
||||
const saved = isNewMember.value
|
||||
? await $fetch('/admin/api/members', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
})
|
||||
: await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||
method: 'PUT',
|
||||
body: payload
|
||||
})
|
||||
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
emit('saved', saved)
|
||||
saveMessage.value = '저장되었습니다.'
|
||||
await performMemberSave()
|
||||
showToast('success', '저장되었습니다.')
|
||||
} catch (error) {
|
||||
saveError.value = error?.data?.message || '저장에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -401,22 +705,26 @@ watch(() => props.member, () => {
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-member-form__actions flex items-center gap-3">
|
||||
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
|
||||
<div v-show="selectedMemberTab === 'info'" class="admin-member-form__actions flex items-center gap-3">
|
||||
<div v-if="!isNewMember && canManageMemberActions" class="admin-member-form__action-menu relative">
|
||||
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
|
||||
<AdminPopoverSurface
|
||||
v-if="actionMenuOpen"
|
||||
root-class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 rounded-xl border-line py-2 text-sm text-[#3f4650]"
|
||||
clipped
|
||||
>
|
||||
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
|
||||
멤버 삭제
|
||||
</button>
|
||||
</div>
|
||||
</AdminPopoverSurface>
|
||||
</div>
|
||||
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -424,14 +732,39 @@ 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) || (!isFormEditable && !canOpenEditMode)"
|
||||
@click="saveMember"
|
||||
>
|
||||
{{ !isFormEditable ? (canOpenEditMode ? '수정하기' : '수정 불가') : isSaving ? '저장 중' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="!isNewMember && !canEditMemberProfile"
|
||||
class="admin-member-form__readonly-note mt-4 rounded-md border border-line bg-[#f9f9f7] px-4 py-3 text-sm text-[#657080]"
|
||||
>
|
||||
관리자는 VIP·멤버 정보만 수정할 수 있습니다. 소유자·관리자 계정은 조회만 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
|
||||
<nav v-if="!isNewMember" class="admin-member-form__tabs mt-5 flex gap-6 border-b border-line" aria-label="멤버 상세 메뉴">
|
||||
<button
|
||||
v-for="tab in memberTabs" :key="tab.value" type="button"
|
||||
class="admin-member-form__tab border-b-2 px-1 py-3 text-sm font-semibold"
|
||||
:class="selectedMemberTab === tab.value ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
|
||||
:aria-current="selectedMemberTab === tab.value ? 'page' : undefined"
|
||||
@click="selectedMemberTab = tab.value"
|
||||
>{{ tab.label }}</button>
|
||||
</nav>
|
||||
<AdminMemberActivityList
|
||||
v-if="!isNewMember && selectedMemberTab !== 'info'"
|
||||
:key="`${member.id}-${selectedMemberTab}`" :member-id="member.id"
|
||||
class="admin-member-form__content-tab mt-6"
|
||||
/>
|
||||
<div v-show="selectedMemberTab === 'info'" class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
|
||||
<aside class="admin-member-form__summary">
|
||||
<div class="admin-member-form__identity flex items-center gap-4">
|
||||
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
|
||||
@@ -439,6 +772,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 +786,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,35 +848,102 @@ 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__google">
|
||||
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">Google 로그인</h2>
|
||||
<div class="admin-member-form__google-card rounded-xl border border-line bg-white px-5 py-5 md:px-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-[#15171a]">
|
||||
{{ googleLinked ? `${googleAccountText} 연결됨` : '연결된 Google 계정 없음' }}
|
||||
</p>
|
||||
<p v-if="googleLinked" class="mt-1 text-sm text-[#8a95a5]">
|
||||
연결일 {{ formatDate(member?.googleLinkedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
class="admin-member-form__google-badge inline-flex h-9 items-center rounded-full px-4 text-sm font-semibold"
|
||||
:class="googleLinked ? 'bg-[#eef1f4] text-[#8a95a5]' : 'bg-[#f7f8fa] text-[#b3bbc5]'"
|
||||
>
|
||||
{{ googleLinked ? '연결 됨' : '미연결' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canUnlinkGoogle"
|
||||
class="admin-member-form__google-unlink h-9 rounded-md border border-[#ffd2d6] px-4 text-sm font-semibold text-[#d21a26] transition hover:bg-[#fff1f2]"
|
||||
type="button"
|
||||
@click="openGoogleUnlinkModal"
|
||||
>
|
||||
연동 해제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!isNewMember" class="admin-member-form__activity">
|
||||
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
|
||||
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
|
||||
@@ -576,6 +978,8 @@ watch(() => props.member, () => {
|
||||
@leave="leaveUnsavedPage"
|
||||
/>
|
||||
|
||||
<AdminToast :toast="toast" />
|
||||
|
||||
<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)]">
|
||||
@@ -642,5 +1046,62 @@ watch(() => props.member, () => {
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="googleUnlinkModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||
<h2 class="text-xl font-semibold">Google 연동 해제</h2>
|
||||
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeGoogleUnlinkModal">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="grid gap-4 px-6 py-5">
|
||||
<p class="text-sm leading-6 text-[#657080]">
|
||||
<strong class="text-[#15171a]">{{ form.username || form.email }}</strong> 님의 Google 로그인 연동을 해제합니다. 해제 후에는 이 Google 계정으로 로그인할 수 없습니다.
|
||||
</p>
|
||||
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||
</div>
|
||||
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeGoogleUnlinkModal">
|
||||
취소
|
||||
</button>
|
||||
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUnlinkingGoogle" @click="unlinkGoogleIdentity">
|
||||
{{ isUnlinkingGoogle ? '해제 중' : '연동 해제' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="ownershipTransferModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||
<h2 class="text-xl font-semibold">소유자 권한 이전</h2>
|
||||
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeOwnershipTransferModal">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="grid gap-4 px-6 py-5">
|
||||
<p class="text-sm leading-6 text-[#657080]">
|
||||
<strong class="text-[#15171a]">{{ form.username || form.email }}</strong> 님을 새로운 소유자로 지정하시겠습니까?<br> 소유자는 한 명만 설정할 수 있으며, 변경 후 현재 계정은 <strong class="text-[#15171a]">관리자 권한</strong>으로 전환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeOwnershipTransferModal">
|
||||
취소
|
||||
</button>
|
||||
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isTransferringOwnership" @click="confirmOwnershipTransfer">
|
||||
{{ isTransferringOwnership ? '이전 중' : '권한 이전' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
44
components/admin/AdminModalFrame.vue
Normal file
44
components/admin/AdminModalFrame.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 모달 제목 요소 ID */
|
||||
labelledBy: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 오버레이에 추가할 화면별 클래스 */
|
||||
rootClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 패널에 추가할 화면별 클래스 */
|
||||
panelClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 오버레이 z-index 클래스 */
|
||||
zClass: {
|
||||
type: String,
|
||||
default: 'z-[60]'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-modal-frame fixed inset-0 grid place-items-center bg-black/40 px-5 py-8"
|
||||
:class="[zClass, rootClass]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="props.labelledBy"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<section
|
||||
class="admin-modal-frame__panel max-h-[90vh] w-full overflow-y-auto rounded border border-line bg-white p-5 shadow-xl"
|
||||
:class="panelClass"
|
||||
>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
87
components/admin/AdminPagination.vue
Normal file
87
components/admin/AdminPagination.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { buildAdminPaginationItems, clampAdminPage } from '~/lib/admin-pagination.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 현재 페이지 */
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/** 전체 페이지 수 */
|
||||
pageCount: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/** 페이지네이션 영역 접근성 라벨 */
|
||||
label: {
|
||||
type: String,
|
||||
default: '페이지'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:page'])
|
||||
|
||||
/** @type {import('vue').ComputedRef<number>} */
|
||||
const totalPageCount = computed(() => Math.max(1, Number(props.pageCount) || 1))
|
||||
|
||||
/** @type {import('vue').ComputedRef<number>} */
|
||||
const currentPage = computed(() => clampAdminPage(props.page, totalPageCount.value))
|
||||
|
||||
/** @type {import('vue').ComputedRef<Array<number|string>>} */
|
||||
const paginationItems = computed(() => buildAdminPaginationItems(currentPage.value, totalPageCount.value))
|
||||
|
||||
/**
|
||||
* 페이지 변경을 요청한다.
|
||||
* @param {number} page - 이동할 페이지
|
||||
* @returns {void}
|
||||
*/
|
||||
const updatePage = (page) => {
|
||||
emit('update:page', clampAdminPage(page, totalPageCount.value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
v-if="totalPageCount > 1"
|
||||
class="admin-pagination mt-6 flex flex-wrap items-center justify-center gap-2"
|
||||
:aria-label="label"
|
||||
>
|
||||
<button
|
||||
class="admin-pagination__arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="currentPage <= 1"
|
||||
aria-label="이전 페이지"
|
||||
@click="updatePage(currentPage - 1)"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<template v-for="item in paginationItems" :key="item">
|
||||
<span v-if="typeof item === 'string'" class="admin-pagination__ellipsis px-2 text-sm font-semibold text-muted">
|
||||
...
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
class="admin-pagination__page grid size-9 place-items-center rounded-full text-sm font-semibold transition"
|
||||
:class="item === currentPage ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-white hover:text-ink'"
|
||||
type="button"
|
||||
:aria-current="item === currentPage ? 'page' : undefined"
|
||||
@click="updatePage(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="admin-pagination__arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="currentPage >= totalPageCount"
|
||||
aria-label="다음 페이지"
|
||||
@click="updatePage(currentPage + 1)"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
41
components/admin/AdminPopoverSurface.vue
Normal file
41
components/admin/AdminPopoverSurface.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 렌더링할 HTML 태그 */
|
||||
as: {
|
||||
type: String,
|
||||
default: 'div'
|
||||
},
|
||||
/** 팝오버 접근성 role */
|
||||
role: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 팝오버에 추가할 화면별 클래스 */
|
||||
rootClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 팝오버에 적용할 인라인 스타일 */
|
||||
surfaceStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
/** 기본 overflow 숨김 사용 여부 */
|
||||
clipped: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.as"
|
||||
class="admin-popover-surface border bg-white shadow-[0_18px_50px_rgba(15,23,42,0.14)]"
|
||||
:class="[clipped ? 'overflow-hidden' : '', rootClass]"
|
||||
:style="surfaceStyle"
|
||||
:role="role || undefined"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</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>
|
||||
229
components/admin/AdminPostExportJobList.vue
Normal file
229
components/admin/AdminPostExportJobList.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 게시물 내보내기 작업 목록 카드
|
||||
* @property {Array} jobs - Export 작업 목록
|
||||
* @property {Function} formatDateTime - 날짜 표시 함수
|
||||
* @property {Function} getStatusLabel - 상태 라벨 함수
|
||||
* @property {Function} getStatusClass - 상태 배지 클래스 함수
|
||||
* @property {Function} shouldShowProgress - 진행도 표시 여부 함수
|
||||
* @property {Function} getProgressLabel - 진행 숫자 라벨 함수
|
||||
* @property {Function} getProgressPercent - 진행률 함수
|
||||
* @property {Function} getProgressDescription - 진행 보조 설명 함수
|
||||
* @property {Function} isRetrying - 재시도 중 여부 함수
|
||||
* @property {Function} isDeleting - 삭제 중 여부 함수
|
||||
* @property {Function} isDownloading - 다운로드 중 여부 함수
|
||||
* @property {Function} areAllReadyFilesSelected - 전체 선택 여부 함수
|
||||
* @property {Function} getReadyFiles - 준비 완료 파일 목록 함수
|
||||
* @property {Function} getSelectedReadyFiles - 선택된 준비 완료 파일 목록 함수
|
||||
* @property {Function} isFileSelected - 파일 선택 여부 함수
|
||||
* @property {Function} isFileSelectionDisabled - 파일 선택 비활성 여부 함수
|
||||
*/
|
||||
defineProps({
|
||||
jobs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
formatDateTime: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getStatusLabel: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getStatusClass: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
shouldShowProgress: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getProgressLabel: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getProgressPercent: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getProgressDescription: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isRetrying: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isDeleting: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isDownloading: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
areAllReadyFilesSelected: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getReadyFiles: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getSelectedReadyFiles: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isFileSelected: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isFileSelectionDisabled: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['refresh', 'retry', 'delete', 'toggle-all-files', 'download-files', 'toggle-file'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="jobs.length > 0"
|
||||
id="admin-settings-section-export-results"
|
||||
class="admin-post-export-job-list admin-settings-screen__card admin-settings-screen__card--export-results rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div class="admin-settings-screen__export-list">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-[#15171a]">
|
||||
최근 내보내기 작업
|
||||
</h3>
|
||||
<button
|
||||
class="inline-flex h-8 cursor-pointer items-center justify-center rounded px-3 text-xs font-semibold text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
type="button"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-settings-screen__export-items grid gap-3">
|
||||
<article
|
||||
v-for="job in jobs"
|
||||
:key="job.id"
|
||||
class="admin-settings-screen__export-item rounded-lg border border-[#e6e8eb] bg-white p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
v-if="job.status !== 'ready'"
|
||||
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold ring-1 ring-inset"
|
||||
:class="getStatusClass(job.status)"
|
||||
>
|
||||
{{ getStatusLabel(job.status) }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-[#15171a]">
|
||||
게시물 {{ job.postCount }}개
|
||||
</span>
|
||||
<span class="text-sm text-[#9aa3ad]">
|
||||
{{ job.files.length }}개 파일
|
||||
</span>
|
||||
<span class="text-sm text-[#657080]">
|
||||
{{ job.rangeLabel || '전체' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-[#657080]">
|
||||
만료: {{ formatDateTime(job.expiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
v-if="job.status === 'failed'"
|
||||
class="inline-flex h-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] px-3 text-xs font-semibold text-[#15171a] transition hover:bg-[#f4f6f8] disabled:cursor-not-allowed disabled:text-[#a6b0bb]"
|
||||
type="button"
|
||||
:disabled="isRetrying(job.id)"
|
||||
@click="$emit('retry', job)"
|
||||
>
|
||||
{{ isRetrying(job.id) ? '재시도 중' : '재시도' }}
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex h-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#ffd5d5] px-3 text-xs font-semibold text-[#d64545] transition hover:bg-[#fff3f3] disabled:cursor-not-allowed disabled:border-[#e1e5ea] disabled:text-[#a6b0bb]"
|
||||
type="button"
|
||||
:disabled="job.status === 'queued' || job.status === 'processing' || isDeleting(job.id)"
|
||||
@click="$emit('delete', job)"
|
||||
>
|
||||
{{ isDeleting(job.id) ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="shouldShowProgress(job)"
|
||||
class="admin-settings-screen__export-progress mt-4 rounded-lg border border-[#edf0f3] bg-[#fbfcfd] p-3"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-3 text-xs">
|
||||
<span class="font-semibold text-[#15171a]">
|
||||
진행도 {{ getProgressLabel(job) }}
|
||||
</span>
|
||||
<span class="text-[#657080]">
|
||||
{{ getProgressPercent(job) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-[#e8edf2]">
|
||||
<div
|
||||
class="h-full rounded-full bg-[#15171a] transition-[width] duration-500"
|
||||
:style="{ width: `${getProgressPercent(job)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col gap-1 text-xs text-[#657080] md:flex-row md:items-center md:justify-between">
|
||||
<span>{{ getProgressDescription(job) }}</span>
|
||||
<span>마지막 갱신: {{ formatDateTime(job.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details
|
||||
v-if="job.status === 'failed' && job.errorDetail"
|
||||
class="admin-settings-screen__export-error mt-4 rounded-lg border border-[#ffd5d5] bg-[#fff7f7] p-3 text-xs text-[#8f2d2d]"
|
||||
>
|
||||
<summary class="cursor-pointer font-semibold">
|
||||
실패 원인 보기
|
||||
</summary>
|
||||
<pre class="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">{{ job.errorDetail }}</pre>
|
||||
</details>
|
||||
|
||||
<div v-if="job.files.length > 0" class="admin-settings-screen__export-files mt-4 overflow-hidden rounded-md border border-[#edf0f3]">
|
||||
<div class="flex flex-col gap-3 border-b border-[#edf0f3] bg-[#fbfcfd] px-3 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<label class="inline-flex cursor-pointer items-center gap-2 text-xs font-semibold text-[#394047] has-[:disabled]:cursor-not-allowed has-[:disabled]:text-[#a6b0bb]">
|
||||
<input
|
||||
class="size-4 rounded border-[#cfd6de] text-[#15171a] focus:ring-[#15171a] disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
:checked="areAllReadyFilesSelected(job)"
|
||||
:disabled="getReadyFiles(job).length === 0 || isDownloading(job.id)"
|
||||
@change="$emit('toggle-all-files', job)"
|
||||
/>
|
||||
전체 선택
|
||||
</label>
|
||||
<button
|
||||
class="inline-flex h-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#15171a] bg-[#15171a] px-3 text-xs font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:border-[#dce0e5] disabled:bg-white disabled:text-[#9aa3ad]"
|
||||
type="button"
|
||||
:disabled="getSelectedReadyFiles(job).length === 0 || isDownloading(job.id)"
|
||||
@click="$emit('download-files', job)"
|
||||
>
|
||||
{{ isDownloading(job.id) ? '다운로드 중' : `선택 파일 다운로드 ${getSelectedReadyFiles(job).length || ''}` }}
|
||||
</button>
|
||||
</div>
|
||||
<AdminPostExportFileRow
|
||||
v-for="file in job.files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:selected="isFileSelected(job, file)"
|
||||
:disabled="isFileSelectionDisabled(job, file)"
|
||||
@toggle="$emit('toggle-file', job, file)"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
261
components/admin/AdminPostExportRequestCard.vue
Normal file
261
components/admin/AdminPostExportRequestCard.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 게시물 내보내기 요청 카드
|
||||
* @property {boolean} expanded - 펼침 여부
|
||||
* @property {string} dateRangeMode - 내보내기 범위 모드
|
||||
* @property {number} year - 내보내기 연도
|
||||
* @property {number} month - 내보내기 월
|
||||
* @property {string} dateFrom - 직접 범위 시작일
|
||||
* @property {string} dateTo - 직접 범위 종료일
|
||||
* @property {number} maxFileSizeMb - 목표 ZIP 용량
|
||||
* @property {number} chunkSize - ZIP당 최대 게시물 수
|
||||
* @property {Array} dateRangeModeOptions - 범위 옵션
|
||||
* @property {Array} yearOptions - 연도 옵션
|
||||
* @property {Array} monthOptions - 월 옵션
|
||||
* @property {boolean} canRequest - 요청 가능 여부
|
||||
* @property {boolean} requesting - 요청 중 여부
|
||||
* @property {boolean} hasActiveJobs - 진행 중 작업 여부
|
||||
* @property {string} requestTitle - 요청 버튼 안내 문구
|
||||
*/
|
||||
defineProps({
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
dateRangeMode: {
|
||||
type: String,
|
||||
default: 'all'
|
||||
},
|
||||
year: {
|
||||
type: [Number, String],
|
||||
default: () => new Date().getFullYear()
|
||||
},
|
||||
month: {
|
||||
type: [Number, String],
|
||||
default: 1
|
||||
},
|
||||
dateFrom: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
dateTo: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
maxFileSizeMb: {
|
||||
type: [Number, String],
|
||||
default: 500
|
||||
},
|
||||
chunkSize: {
|
||||
type: [Number, String],
|
||||
default: 500
|
||||
},
|
||||
dateRangeModeOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
yearOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
monthOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
canRequest: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
requesting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasActiveJobs: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
requestTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'toggle',
|
||||
'request',
|
||||
'update:dateRangeMode',
|
||||
'update:year',
|
||||
'update:month',
|
||||
'update:dateFrom',
|
||||
'update:dateTo',
|
||||
'update:maxFileSizeMb',
|
||||
'update:chunkSize'
|
||||
])
|
||||
|
||||
/**
|
||||
* 입력 이벤트 값을 문자열로 전달한다.
|
||||
* @param {Event} event - 입력 이벤트
|
||||
* @param {string} eventName - 전달할 이벤트 이름
|
||||
* @returns {void}
|
||||
*/
|
||||
const emitTextInputValue = (event, eventName) => {
|
||||
emit(eventName, event.target?.value || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 입력 이벤트 값을 전달한다.
|
||||
* @param {Event} event - 입력 이벤트
|
||||
* @param {string} eventName - 전달할 이벤트 이름
|
||||
* @returns {void}
|
||||
*/
|
||||
const emitNumberInputValue = (event, eventName) => {
|
||||
const value = event.target?.value ?? ''
|
||||
emit(eventName, value === '' ? '' : Number(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-export"
|
||||
class="admin-post-export-request-card admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div class="admin-settings-screen__card-head flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-[#15171a]">
|
||||
게시물 내보내기
|
||||
</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
게시물을 Obsidian 호환 ZIP 백업으로 만들고, 준비된 분할 파일을 내려받습니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black"
|
||||
type="button"
|
||||
:aria-expanded="expanded"
|
||||
aria-controls="admin-settings-export-panel"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
{{ expanded ? '접기' : '내보내기' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="expanded"
|
||||
id="admin-settings-export-panel"
|
||||
class="admin-post-export-request-card__actions admin-settings-screen__export-actions mt-4 grid gap-4 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-[#15171a]">
|
||||
Obsidian 호환 백업 준비
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
전체·연도·월·직접 범위로 게시물을 골라 목표 용량 기준 ZIP 백업을 만듭니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div class="admin-post-export-request-card__range admin-settings-screen__export-range grid gap-2 md:grid-cols-4">
|
||||
<div class="grid gap-1 text-xs font-semibold text-[#5d6673]">
|
||||
<span>범위</span>
|
||||
<AdminSelectInput
|
||||
:model-value="dateRangeMode"
|
||||
:options="dateRangeModeOptions"
|
||||
label="내보내기 범위"
|
||||
select-class="admin-settings-screen__export-select h-10 rounded-md border-[#dce0e5] text-sm font-semibold text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
width-class="w-full"
|
||||
@update:model-value="emit('update:dateRangeMode', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="dateRangeMode === 'year' || dateRangeMode === 'month'"
|
||||
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
|
||||
>
|
||||
<span>연도</span>
|
||||
<AdminSelectInput
|
||||
:model-value="year"
|
||||
:options="yearOptions"
|
||||
label="내보내기 연도"
|
||||
select-class="admin-settings-screen__export-select h-10 rounded-md border-[#dce0e5] text-sm font-semibold text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
width-class="w-full"
|
||||
@update:model-value="emit('update:year', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="dateRangeMode === 'month'"
|
||||
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
|
||||
>
|
||||
<span>월</span>
|
||||
<AdminSelectInput
|
||||
:model-value="month"
|
||||
:options="monthOptions"
|
||||
label="내보내기 월"
|
||||
select-class="admin-settings-screen__export-select h-10 rounded-md border-[#dce0e5] text-sm font-semibold text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
width-class="w-full"
|
||||
@update:model-value="emit('update:month', $event)"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
v-if="dateRangeMode === 'custom'"
|
||||
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
|
||||
>
|
||||
시작일
|
||||
<input
|
||||
:value="dateFrom"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="date"
|
||||
@input="emitTextInputValue($event, 'update:dateFrom')"
|
||||
>
|
||||
</label>
|
||||
<label
|
||||
v-if="dateRangeMode === 'custom'"
|
||||
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
|
||||
>
|
||||
종료일
|
||||
<input
|
||||
:value="dateTo"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="date"
|
||||
@input="emitTextInputValue($event, 'update:dateTo')"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-post-export-request-card__split admin-settings-screen__export-split grid gap-2 md:grid-cols-2">
|
||||
<label class="grid gap-1 text-xs font-semibold text-[#5d6673]">
|
||||
목표 ZIP 용량 (MB)
|
||||
<input
|
||||
:value="maxFileSizeMb"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="number"
|
||||
min="10"
|
||||
max="2048"
|
||||
step="10"
|
||||
@input="emitNumberInputValue($event, 'update:maxFileSizeMb')"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-1 text-xs font-semibold text-[#5d6673]">
|
||||
ZIP당 최대 게시물 수
|
||||
<input
|
||||
:value="chunkSize"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
step="1"
|
||||
@input="emitNumberInputValue($event, 'update:chunkSize')"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
class="admin-post-export-request-card__request admin-settings-screen__export-request inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#c7cdd4]"
|
||||
type="button"
|
||||
:disabled="!canRequest"
|
||||
:title="requestTitle"
|
||||
@click="emit('request')"
|
||||
>
|
||||
{{ requesting ? '요청 중...' : hasActiveJobs ? '내보내기 진행 중' : '내보내기 요청' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
149
components/admin/AdminPostImportCard.vue
Normal file
149
components/admin/AdminPostImportCard.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
importing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
hasFile: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasJobs: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'select-file', 'submit', 'open-jobs'])
|
||||
const inputRef = ref(null)
|
||||
|
||||
/**
|
||||
* 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openFilePicker = () => {
|
||||
inputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 선택 이벤트를 부모로 전달한다.
|
||||
* @param {Event} event - 파일 선택 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleFileChange = (event) => {
|
||||
const target = event.target
|
||||
const file = target instanceof HTMLInputElement ? target.files?.[0] : null
|
||||
|
||||
emit('select-file', file || null)
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 드롭 이벤트를 부모로 전달한다.
|
||||
* @param {DragEvent} event - 파일 드롭 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleFileDrop = (event) => {
|
||||
emit('select-file', event.dataTransfer?.files?.[0] || null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-import"
|
||||
class="admin-post-import-card admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div class="admin-settings-screen__card-head flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-[#15171a]">
|
||||
게시물 가져오기
|
||||
</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
내보내기로 만든 ZIP 백업을 게시물과 미디어 파일로 복원합니다. 이미 같은 slug가 있는 게시물은 건너뜁니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f4f6f8]"
|
||||
type="button"
|
||||
:aria-expanded="expanded"
|
||||
aria-controls="admin-settings-import-panel"
|
||||
:disabled="importing"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
{{ expanded ? '접기' : '가져오기' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="expanded"
|
||||
id="admin-settings-import-panel"
|
||||
class="admin-post-import-card__actions admin-settings-screen__import-actions mt-5 grid gap-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#fbfcfd] p-4"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
:disabled="importing"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<button
|
||||
class="admin-post-import-card__dropzone admin-settings-screen__import-dropzone grid min-h-32 cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-white px-4 py-6 text-center transition hover:border-[#15171a] hover:bg-[#f7f8fa] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="importing"
|
||||
@click="openFilePicker"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<span class="grid place-items-center gap-2 text-sm font-semibold text-[#657080]">
|
||||
<svg class="size-6 text-[#8a94a3]" xmlns="http://www.w3.org/2000/svg" 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 16V4" />
|
||||
<path d="m7 9 5-5 5 5" />
|
||||
<path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />
|
||||
</svg>
|
||||
<span>{{ fileName || 'ZIP 파일을 드롭하거나 선택하세요.' }}</span>
|
||||
<span class="text-xs font-medium text-[#8a94a3]">
|
||||
같은 slug의 게시물은 덮어쓰지 않고 건너뛰며, 건너뛴 게시물의 자산도 다시 저장하지 않습니다.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="importing"
|
||||
class="admin-post-import-card__progress admin-settings-screen__import-progress rounded-md bg-[#eef5ff] px-3 py-2 text-sm text-[#1f5f99] ring-1 ring-inset ring-[#bdd8f3]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
ZIP 파일을 서버 작업 대기열에 등록하는 중입니다.
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
v-if="hasJobs"
|
||||
class="mr-auto inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f4f6f8]"
|
||||
type="button"
|
||||
@click="emit('open-jobs')"
|
||||
>
|
||||
작업 센터
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#c7cdd4]"
|
||||
type="button"
|
||||
:disabled="importing || !hasFile"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ importing ? '가져오는 중...' : '적용' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
267
components/admin/AdminPostImportJobModal.vue
Normal file
267
components/admin/AdminPostImportJobModal.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
job: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
deleting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'refresh', 'delete'])
|
||||
|
||||
/**
|
||||
* 날짜·시간을 관리자 화면용 문자열로 변환한다.
|
||||
* @param {string|null} value - ISO 날짜 문자열
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import 작업 진행률을 계산한다.
|
||||
* @param {Object|null} job - Import 작업
|
||||
* @returns {number} 진행률
|
||||
*/
|
||||
const getProgressPercent = (job) => {
|
||||
const totalCount = Number(job?.totalCount || 0)
|
||||
|
||||
if (job?.status === 'ready') {
|
||||
return 100
|
||||
}
|
||||
|
||||
if (totalCount <= 0) {
|
||||
return job?.status === 'processing' ? 5 : 0
|
||||
}
|
||||
|
||||
return Math.min(Math.round((Number(job.processedCount || 0) / totalCount) * 100), 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import 작업 진행 라벨을 만든다.
|
||||
* @param {Object|null} job - Import 작업
|
||||
* @returns {string} 진행 라벨
|
||||
*/
|
||||
const getProgressLabel = (job) => {
|
||||
const processedCount = Number(job?.processedCount || 0)
|
||||
const totalCount = Number(job?.totalCount || 0)
|
||||
|
||||
if (totalCount <= 0) {
|
||||
return '준비 중'
|
||||
}
|
||||
|
||||
return `${processedCount.toLocaleString()} / ${totalCount.toLocaleString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Import 상태 라벨을 반환한다.
|
||||
* @param {string} status - 상태
|
||||
* @returns {string} 상태 라벨
|
||||
*/
|
||||
const getStatusLabel = (status) => ({
|
||||
queued: '대기',
|
||||
processing: '진행중',
|
||||
ready: '완료',
|
||||
skipped: '스킵',
|
||||
failed: '오류',
|
||||
pending: '대기'
|
||||
})[status] || '대기'
|
||||
|
||||
/**
|
||||
* Import 상태 스타일을 반환한다.
|
||||
* @param {string} status - 상태
|
||||
* @returns {string} 클래스 문자열
|
||||
*/
|
||||
const getStatusClass = (status) => ({
|
||||
queued: 'bg-[#eef3ff] text-[#2458b8]',
|
||||
processing: 'bg-[#eef3ff] text-[#2458b8]',
|
||||
ready: 'bg-[#eefbf3] text-[#147a45]',
|
||||
skipped: 'bg-[#f3f5f7] text-[#657080]',
|
||||
failed: 'bg-[#fff1f2] text-[#c01f32]',
|
||||
pending: 'bg-[#f3f5f7] text-[#657080]'
|
||||
})[status] || 'bg-[#f3f5f7] text-[#657080]'
|
||||
|
||||
/**
|
||||
* Import 작업 삭제 가능 여부를 확인한다.
|
||||
* @returns {boolean} 삭제 가능 여부
|
||||
*/
|
||||
const canDeleteJob = computed(() => Boolean(props.job)
|
||||
&& props.job.status !== 'queued'
|
||||
&& props.job.status !== 'processing'
|
||||
&& !props.deleting)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="admin-post-import-job-modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-post-import-job-modal-title"
|
||||
>
|
||||
<div class="flex max-h-[calc(100vh-4rem)] w-full max-w-5xl flex-col overflow-hidden rounded-xl bg-white shadow-xl">
|
||||
<header class="flex shrink-0 items-center justify-between gap-4 border-b border-[#eceff2] px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<h2 id="admin-post-import-job-modal-title" class="text-base font-semibold text-[#15171a]">
|
||||
게시물 가져오기 작업 센터
|
||||
</h2>
|
||||
<p class="mt-1 truncate text-xs text-[#657080]">
|
||||
{{ job?.originalFileName || '최근 작업' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="grid size-8 shrink-0 cursor-pointer place-items-center rounded-md text-[#657080] transition hover:bg-[#f3f5f7] hover:text-[#15171a]"
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="job" class="grid min-h-0 gap-4 overflow-auto px-5 py-4">
|
||||
<div class="grid gap-3 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold"
|
||||
:class="getStatusClass(job.status)"
|
||||
>
|
||||
{{ getStatusLabel(job.status) }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-[#15171a]">
|
||||
{{ getProgressLabel(job) }}
|
||||
</span>
|
||||
<span class="text-sm text-[#657080]">
|
||||
완료 {{ job.importedCount }} · 스킵 {{ job.skippedCount }} · 오류 {{ job.failedCount }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-[#657080]">
|
||||
마지막 갱신: {{ formatDateTime(job.updatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-[#e8edf2]">
|
||||
<div
|
||||
class="h-full rounded-full bg-[#15171a] transition-[width] duration-500"
|
||||
:style="{ width: `${getProgressPercent(job)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="job.message" class="text-xs text-[#657080]">
|
||||
{{ job.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-[#e6e8eb]">
|
||||
<div class="grid min-w-[680px] grid-cols-[minmax(0,1fr)_220px_110px] gap-4 border-b border-[#eceff2] bg-[#fbfcfd] px-4 py-3 text-xs font-semibold text-[#394047]">
|
||||
<span>게시물 제목</span>
|
||||
<span>프로세스</span>
|
||||
<span>상태</span>
|
||||
</div>
|
||||
<div v-if="job.items.length > 0" class="max-h-[52vh] overflow-auto">
|
||||
<div
|
||||
v-for="item in job.items"
|
||||
:key="item.id"
|
||||
class="grid min-w-[680px] grid-cols-[minmax(0,1fr)_220px_110px] gap-4 border-b border-[#edf0f3] px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-[#15171a]">
|
||||
{{ item.title || item.sourcePath }}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-xs text-[#8a94a3]">
|
||||
{{ item.slug || item.sourcePath }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid content-center gap-1">
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-[#e8edf2]">
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-500"
|
||||
:class="item.status === 'failed' ? 'bg-[#d64545]' : item.status === 'skipped' ? 'bg-[#9aa3ad]' : 'bg-[#20a04b]'"
|
||||
:style="{ width: `${Number(item.progress || 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="truncate text-xs text-[#657080]">
|
||||
{{ Number(item.progress || 0) }}% {{ item.message || getStatusLabel(item.status) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold"
|
||||
:class="getStatusClass(item.status)"
|
||||
>
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 py-10 text-center text-sm text-[#657080]">
|
||||
게시물 목록을 준비하는 중입니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details
|
||||
v-if="job.status === 'failed' && job.errorDetail"
|
||||
class="rounded-lg border border-[#ffd5d5] bg-[#fff7f7] p-3 text-xs text-[#8f2d2d]"
|
||||
>
|
||||
<summary class="cursor-pointer font-semibold">
|
||||
실패 원인 보기
|
||||
</summary>
|
||||
<pre class="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">{{ job.errorDetail }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<footer class="flex shrink-0 items-center justify-between gap-3 border-t border-[#eceff2] px-5 py-4">
|
||||
<button
|
||||
class="inline-flex h-9 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] bg-white px-3 text-xs font-semibold text-[#15171a] transition hover:bg-[#f4f6f8]"
|
||||
type="button"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="job"
|
||||
class="inline-flex h-9 cursor-pointer items-center justify-center rounded-md border border-[#ffd5d5] bg-white px-3 text-xs font-semibold text-[#d64545] transition hover:bg-[#fff3f3] disabled:cursor-not-allowed disabled:border-[#e1e5ea] disabled:text-[#a6b0bb]"
|
||||
type="button"
|
||||
:disabled="!canDeleteJob"
|
||||
@click="emit('delete', job)"
|
||||
>
|
||||
{{ deleting ? '삭제 중' : '완료 삭제' }}
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex h-9 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-xs font-semibold text-white transition hover:bg-black"
|
||||
type="button"
|
||||
@click="emit('close')"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
274
components/admin/AdminPostSettingsCard.vue
Normal file
274
components/admin/AdminPostSettingsCard.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<script setup>
|
||||
import {
|
||||
DEFAULT_FEATURED_POST_LIMIT,
|
||||
DEFAULT_HOME_LATEST_POST_LIMIT,
|
||||
DEFAULT_PAGE_SECTION_ITEM_LIMIT,
|
||||
DEFAULT_POST_LIST_PAGE_SIZE,
|
||||
DEFAULT_TAG_POST_LIST_PAGE_SIZE,
|
||||
normalizeDisplayCount
|
||||
} from '~/lib/display-count-settings.js'
|
||||
import {
|
||||
DEFAULT_POST_TAG_LIMIT,
|
||||
MAX_POST_TAG_LIMIT,
|
||||
MIN_POST_TAG_LIMIT,
|
||||
normalizePostTagLimit
|
||||
} from '~/lib/post-tag-limit.js'
|
||||
import {
|
||||
formatUploadSizeLimit,
|
||||
normalizeUploadMaxFileCount,
|
||||
normalizeUploadMaxTotalSize
|
||||
} from '~/lib/upload-size-limit.js'
|
||||
|
||||
/**
|
||||
* POST 설정 카드
|
||||
* @property {Object} form - 사이트 설정 폼 객체
|
||||
* @property {boolean} editing - 편집 모드 여부
|
||||
* @property {boolean} saving - 저장 중 여부
|
||||
* @property {boolean} hasChanges - 변경 여부
|
||||
* @property {Array<{ value: number, label: string }>} postListCountOptions - 게시물 목록 표시개수 선택지
|
||||
* @property {Array<{ value: number, label: string }>} displayCountOptions - 일반 표시개수 선택지
|
||||
*/
|
||||
defineProps({
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasChanges: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
postListCountOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
displayCountOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['begin', 'cancel', 'save'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-post"
|
||||
class="admin-post-settings-card admin-settings-screen__card admin-settings-screen__card--post 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">
|
||||
POST 설정
|
||||
</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-post-settings-card__readonly admin-settings-screen__post-readonly border-t border-[#eceff2] pt-5 text-sm"
|
||||
>
|
||||
<div class="admin-settings-screen__post-toggle flex items-center justify-between gap-4">
|
||||
<span class="font-bold text-[#15171a]">수정일 표시</span>
|
||||
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 cursor-not-allowed items-center opacity-55 grayscale" aria-disabled="true">
|
||||
<input
|
||||
:checked="form.showPostUpdatedAt"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
disabled
|
||||
aria-label="수정일 표시"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#6d747d]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-[#f4f6f8] shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5">
|
||||
<span class="font-bold text-[#15171a]">태그 최대 개수</span>
|
||||
<span class="font-mono text-sm font-semibold text-[#657080]">
|
||||
{{ normalizePostTagLimit(form.postTagLimit) }}개
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-bold text-[#15171a]">표시개수 설정</span>
|
||||
<span class="text-xs text-[#657080]">공개 목록 기준</span>
|
||||
</div>
|
||||
<div class="grid gap-2 text-xs text-[#657080] sm:grid-cols-2">
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
|
||||
<span>Latest</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT) }}개</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
|
||||
<span>Posts</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE) }}개</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
|
||||
<span>태그 게시물</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE) }}개</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
|
||||
<span>Featured</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT) }}개</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2 sm:col-span-2">
|
||||
<span>Pages 섹션</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT) }}개</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-bold text-[#15171a]">업로드 제한</span>
|
||||
<span class="text-xs text-[#657080]">1회 업로드 기준</span>
|
||||
</div>
|
||||
<div class="grid gap-2 text-xs text-[#657080] sm:grid-cols-2">
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
|
||||
<span>파일 수</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ normalizeUploadMaxFileCount(form.uploadMaxFileCount) }}개</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
|
||||
<span>전체 용량</span>
|
||||
<strong class="font-mono text-[#15171a]">{{ formatUploadSizeLimit(normalizeUploadMaxTotalSize(form.uploadMaxTotalSize)) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="admin-post-settings-card__edit grid gap-5 border-t border-[#eceff2] pt-5 text-sm"
|
||||
>
|
||||
<label class="admin-settings-screen__post-toggle flex items-center justify-between gap-4">
|
||||
<span class="font-bold text-[#15171a]">수정일 표시</span>
|
||||
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
v-model="form.showPostUpdatedAt"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="수정일 표시"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
<label class="admin-settings-screen__field grid gap-2">
|
||||
<span class="font-bold text-[#15171a]">태그 최대 개수</span>
|
||||
<input
|
||||
v-model.number="form.postTagLimit"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="number"
|
||||
:min="MIN_POST_TAG_LIMIT"
|
||||
:max="MAX_POST_TAG_LIMIT"
|
||||
step="1"
|
||||
@blur="form.postTagLimit = normalizePostTagLimit(form.postTagLimit)"
|
||||
>
|
||||
<span class="text-xs leading-relaxed text-[#657080]">
|
||||
최소 {{ MIN_POST_TAG_LIMIT }}개, 최대 {{ MAX_POST_TAG_LIMIT }}개까지 설정할 수 있습니다. 기본값은 {{ DEFAULT_POST_TAG_LIMIT }}개입니다.
|
||||
</span>
|
||||
</label>
|
||||
<div class="admin-settings-screen__field grid gap-3 border-t border-[#eceff2] pt-5">
|
||||
<div>
|
||||
<span class="font-bold text-[#15171a]">표시개수 설정</span>
|
||||
<p class="mt-1 text-xs leading-relaxed text-[#657080]">
|
||||
공개 목록에서 한 번에 불러오고 보여줄 개수를 선택합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-xs font-semibold text-[#657080]">Latest</span>
|
||||
<AdminSelectInput
|
||||
v-model="form.homeLatestPostLimit"
|
||||
:options="postListCountOptions"
|
||||
label="Latest 표시개수"
|
||||
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
width-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-xs font-semibold text-[#657080]">Posts</span>
|
||||
<AdminSelectInput
|
||||
v-model="form.postListPageSize"
|
||||
:options="postListCountOptions"
|
||||
label="Posts 표시개수"
|
||||
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
width-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-xs font-semibold text-[#657080]">태그 게시물</span>
|
||||
<AdminSelectInput
|
||||
v-model="form.tagPostListPageSize"
|
||||
:options="postListCountOptions"
|
||||
label="태그 게시물 표시개수"
|
||||
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
width-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-xs font-semibold text-[#657080]">Featured</span>
|
||||
<AdminSelectInput
|
||||
v-model="form.featuredPostLimit"
|
||||
:options="displayCountOptions"
|
||||
label="Featured 표시개수"
|
||||
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
width-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-1.5 sm:col-span-2">
|
||||
<span class="text-xs font-semibold text-[#657080]">Pages 섹션</span>
|
||||
<AdminSelectInput
|
||||
v-model="form.pageSectionItemLimit"
|
||||
:options="displayCountOptions"
|
||||
label="Pages 섹션 표시개수"
|
||||
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
width-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
523
components/admin/AdminPostSettingsSidebar.vue
Normal file
523
components/admin/AdminPostSettingsSidebar.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<script setup>
|
||||
const slugInputValue = defineModel('slugInputValue', {
|
||||
type: String,
|
||||
default: ''
|
||||
})
|
||||
const settingsPublishDate = defineModel('settingsPublishDate', {
|
||||
type: String,
|
||||
default: ''
|
||||
})
|
||||
const settingsPublishTime = defineModel('settingsPublishTime', {
|
||||
type: String,
|
||||
default: ''
|
||||
})
|
||||
const tagInput = defineModel('tagInput', {
|
||||
type: String,
|
||||
default: ''
|
||||
})
|
||||
const activeTagSuggestionIndex = defineModel('activeTagSuggestionIndex', {
|
||||
type: Number,
|
||||
default: 0
|
||||
})
|
||||
const searchEngineVisible = defineModel('searchEngineVisible', {
|
||||
type: Boolean,
|
||||
default: true
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
canAddMoreTags: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
canViewPost: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
createTagBadgeStyle: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
deleting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
editorBlockPanel: {
|
||||
type: Object,
|
||||
default: () => ({ open: false, panel: null })
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
formatStatNumber: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getTagColor: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getTagDisplayName: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getTagSuggestionMeta: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
hasTagSuggestions: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasUnsavedSlugChange: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isScheduledPost: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isSlugAutoFromTitle: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
postTagLimit: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
postUrlHint: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
postWritingStats: {
|
||||
type: Object,
|
||||
default: () => ({ words: 0, characters: 0, spaces: 0, readingMinutes: 0, blocks: 0, images: 0 })
|
||||
},
|
||||
selectedTags: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
slugFieldError: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
tagSuggestionOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
viewPostUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'add-gallery-images',
|
||||
'close',
|
||||
'commit-slug-input',
|
||||
'delete-post',
|
||||
'move-gallery-image',
|
||||
'open-date-time-picker',
|
||||
'panel-focus-in',
|
||||
'panel-focus-out',
|
||||
'remove-media-image',
|
||||
'remove-tag',
|
||||
'select-tag-suggestion',
|
||||
'set-media-use-alt',
|
||||
'set-tag-input-ref',
|
||||
'tag-composition-end',
|
||||
'tag-composition-start',
|
||||
'toggle-tag-suggestions',
|
||||
'open-tag-suggestions',
|
||||
'tag-blur',
|
||||
'tag-keydown',
|
||||
'update-callout-options',
|
||||
'update-code-options',
|
||||
'update-embed-url',
|
||||
'update-inline-color',
|
||||
'update-media-image',
|
||||
'update-quote-background',
|
||||
'update-toggle-options'
|
||||
])
|
||||
|
||||
/**
|
||||
* 태그 입력 요소 참조를 부모 태그 상태로 전달한다.
|
||||
* @param {HTMLInputElement | null} element - 태그 입력 요소
|
||||
* @returns {void}
|
||||
*/
|
||||
const setTagInputRef = (element) => {
|
||||
emit('set-tag-input-ref', element)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="admin-post-settings-sidebar admin-post-form__settings flex h-screen shrink-0 flex-col overflow-hidden border-[#e3e6e8] bg-white transition-[width,border-color] duration-300 ease-out max-lg:fixed max-lg:inset-y-0 max-lg:right-0 max-lg:z-50 max-lg:max-w-[calc(100vw-40px)] max-lg:shadow-[-18px_0_60px_rgba(15,23,42,0.14)]"
|
||||
:class="props.active ? 'w-[420px] border-l' : 'w-0 border-l-0'"
|
||||
:aria-hidden="!props.active"
|
||||
>
|
||||
<div class="admin-post-settings-sidebar__inner admin-post-form__settings-inner relative flex h-full w-[420px] max-lg:w-full flex-col">
|
||||
<div class="admin-post-settings-sidebar__header admin-post-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
|
||||
<h2 class="admin-post-settings-sidebar__title admin-post-form__settings-title text-xl font-bold text-black">
|
||||
게시물 설정
|
||||
</h2>
|
||||
<button class="admin-post-settings-sidebar__close admin-post-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="emit('close')">
|
||||
<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-post-settings-sidebar__body admin-post-form__settings-body grid flex-1 content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
|
||||
<div class="admin-post-settings-sidebar__field admin-post-form__field grid gap-1 text-sm">
|
||||
<div class="admin-post-form__post-url-header flex h-[22px] items-center justify-between">
|
||||
<span class="admin-post-form__label font-bold text-[#15171a]">Post URL</span>
|
||||
<NuxtLink
|
||||
v-if="props.canViewPost && props.viewPostUrl"
|
||||
class="admin-post-form__view-post 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="props.viewPostUrl"
|
||||
target="_blank"
|
||||
>
|
||||
<span>View Post</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<label
|
||||
class="admin-post-form__post-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]"
|
||||
:class="{ 'admin-post-form__post-url-input--auto': props.isSlugAutoFromTitle }"
|
||||
>
|
||||
<span class="admin-post-form__post-url-icon text-sm text-[#394047]" aria-hidden="true">⌘</span>
|
||||
<input
|
||||
v-model="slugInputValue"
|
||||
class="admin-post-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
|
||||
:class="props.isSlugAutoFromTitle ? 'text-[#8e9cac]' : 'text-black'"
|
||||
type="text"
|
||||
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||
:required="!props.isSlugAutoFromTitle"
|
||||
:aria-describedby="props.slugFieldError ? 'admin-post-form-slug-error' : (props.isSlugAutoFromTitle ? 'admin-post-form-slug-auto-hint' : undefined)"
|
||||
@blur="emit('commit-slug-input')"
|
||||
>
|
||||
</label>
|
||||
<p
|
||||
v-if="props.isSlugAutoFromTitle"
|
||||
id="admin-post-form-slug-auto-hint"
|
||||
class="admin-post-form__post-url-auto-hint text-xs text-[#8e9cac]"
|
||||
>
|
||||
제목에서 자동 생성됩니다. 직접 수정하면 고정됩니다.
|
||||
</p>
|
||||
<p class="admin-post-form__post-url-hint text-xs text-[#7c8b9a]">
|
||||
{{ props.postUrlHint }}
|
||||
<span v-if="props.hasUnsavedSlugChange" class="admin-post-form__post-url-hint-unsaved text-[#8e9cac]"> (저장 후 반영)</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="props.slugFieldError"
|
||||
id="admin-post-form-slug-error"
|
||||
class="admin-post-form__post-url-error text-xs font-semibold text-red-600"
|
||||
>
|
||||
{{ props.slugFieldError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-settings-sidebar__field admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">상태</span>
|
||||
<span class="admin-post-form__select-wrap relative block">
|
||||
<select v-model="props.form.status" class="admin-post-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="members">멤버십</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="props.form.status === 'members'" class="admin-post-form__hint text-xs text-muted">
|
||||
VIP 이상 등급 회원에게만 공개됩니다.
|
||||
</span>
|
||||
<span v-else-if="props.form.status === 'private'" class="admin-post-form__hint text-xs text-muted">
|
||||
공개 사용자 화면에서는 보이지 않습니다.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="props.form.status === 'published'" class="admin-post-settings-sidebar__field admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">발행 시각</span>
|
||||
<div class="admin-post-form__publish-datetime-row grid grid-cols-2 gap-2">
|
||||
<label class="admin-post-form__publish-date-field relative block">
|
||||
<span class="sr-only">발행 날짜</span>
|
||||
<input
|
||||
v-model="settingsPublishDate"
|
||||
class="admin-post-form__publish-date-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-9 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
type="date"
|
||||
>
|
||||
<button
|
||||
class="admin-post-form__publish-date-icon absolute right-1 top-1/2 grid size-8 -translate-y-1/2 place-items-center rounded text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label="발행 날짜 선택"
|
||||
@click="emit('open-date-time-picker', $event)"
|
||||
>
|
||||
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M8 2v3M16 2v3M3 9h18M5 5h14a2 2 0 012 2v13a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2z" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
<label class="admin-post-form__publish-time-field relative block">
|
||||
<span class="sr-only">발행 시각</span>
|
||||
<input
|
||||
v-model="settingsPublishTime"
|
||||
class="admin-post-form__publish-time-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-12 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
type="time"
|
||||
>
|
||||
<button
|
||||
class="admin-post-form__publish-time-zone absolute right-1 top-1/2 grid h-8 min-w-[2.5rem] -translate-y-1/2 place-items-center rounded px-2 text-xs font-medium text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label="발행 시각 선택"
|
||||
@click="emit('open-date-time-picker', $event)"
|
||||
>
|
||||
KST
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ props.isScheduledPost() ? '미래 시각이면 예약 발행으로 저장됩니다.' : '비워두면 저장 시점으로 발행됩니다.' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-settings-sidebar__field admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">요약</span>
|
||||
<textarea
|
||||
v-model="props.form.excerpt"
|
||||
class="admin-post-form__textarea min-h-[108px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="admin-post-settings-sidebar__field admin-post-form__field grid gap-1 text-sm">
|
||||
<span class="admin-post-form__label h-[22px] font-bold text-[#15171a]">Tags</span>
|
||||
<div class="admin-post-form__tag-combobox relative">
|
||||
<div
|
||||
class="admin-post-form__tag-editor flex min-h-[38px] w-full flex-wrap items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]"
|
||||
:class="!props.canAddMoreTags ? 'opacity-85' : ''"
|
||||
>
|
||||
<span
|
||||
v-for="tag in props.selectedTags"
|
||||
:key="tag"
|
||||
class="admin-post-form__tag-badge inline-flex h-6 shrink-0 items-center gap-1.5 rounded-[3px] border px-2 text-sm font-semibold"
|
||||
:style="props.createTagBadgeStyle(props.getTagColor(tag))"
|
||||
>
|
||||
<span>{{ props.getTagDisplayName(tag) }}</span>
|
||||
<button
|
||||
class="admin-post-form__tag-remove inline-flex size-4 shrink-0 items-center justify-center rounded text-current transition-colors hover:bg-white/45"
|
||||
type="button"
|
||||
:aria-label="`${props.getTagDisplayName(tag)} 태그 삭제`"
|
||||
@click="emit('remove-tag', tag)"
|
||||
>
|
||||
<svg class="size-2.5" version="1" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
:ref="setTagInputRef"
|
||||
v-model="tagInput"
|
||||
class="admin-post-form__tag-input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none placeholder:text-[#8e9cac]"
|
||||
type="text"
|
||||
:placeholder="props.canAddMoreTags ? '태그 입력' : `최대 ${props.postTagLimit}개까지 선택됨`"
|
||||
:disabled="!props.canAddMoreTags"
|
||||
role="combobox"
|
||||
:aria-expanded="props.hasTagSuggestions"
|
||||
aria-autocomplete="list"
|
||||
@focus="emit('open-tag-suggestions')"
|
||||
@input="emit('open-tag-suggestions')"
|
||||
@compositionupdate="tagInput = $event.target.value; emit('open-tag-suggestions')"
|
||||
@blur="emit('tag-blur')"
|
||||
@keydown="emit('tag-keydown', $event)"
|
||||
@compositionstart="emit('tag-composition-start')"
|
||||
@compositionend="emit('tag-composition-end')"
|
||||
>
|
||||
<button
|
||||
class="admin-post-form__tag-dropdown-trigger ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded text-[#15171a] transition-colors hover:bg-[#e3e6e8] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac]"
|
||||
type="button"
|
||||
:aria-expanded="props.hasTagSuggestions"
|
||||
:disabled="!props.canAddMoreTags"
|
||||
aria-label="태그 추천 목록 열기"
|
||||
@mousedown.prevent="emit('toggle-tag-suggestions')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down size-5" 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>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-[#8e9cac]">
|
||||
{{ props.selectedTags.length }} / {{ props.postTagLimit }}개 선택됨
|
||||
</p>
|
||||
<AdminPopoverSurface
|
||||
v-if="props.hasTagSuggestions"
|
||||
root-class="admin-post-form__tag-suggestions absolute left-0 right-0 top-full z-40 mt-1 max-h-64 overflow-y-auto rounded-lg border-[#d7dce0] py-1 text-sm"
|
||||
role="listbox"
|
||||
>
|
||||
<button
|
||||
v-for="(tag, index) in props.tagSuggestionOptions"
|
||||
:key="tag.id || tag.slug"
|
||||
class="admin-post-form__tag-suggestion flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors"
|
||||
:class="index === activeTagSuggestionIndex ? 'bg-[#f3f5f7]' : 'hover:bg-[#f7f8f9]'"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="index === activeTagSuggestionIndex"
|
||||
@mouseenter="activeTagSuggestionIndex = index"
|
||||
@mousedown.prevent="emit('select-tag-suggestion', tag)"
|
||||
>
|
||||
<span class="admin-post-form__tag-suggestion-name min-w-0 truncate font-semibold text-[#15171a]">{{ tag.name }}</span>
|
||||
<span class="admin-post-form__tag-suggestion-meta shrink-0 text-xs text-muted">{{ props.getTagSuggestionMeta(tag) }}</span>
|
||||
</button>
|
||||
</AdminPopoverSurface>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="admin-post-form__featured-image-display-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm"
|
||||
>
|
||||
<span class="admin-post-form__featured-image-display-copy flex min-w-0 items-center gap-3">
|
||||
<span class="admin-post-form__featured-image-display-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
|
||||
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m7 15 3-3 2 2 3-4 2 5" />
|
||||
<circle cx="8" cy="9" r="1" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="admin-post-form__featured-image-display-label grid min-w-0 gap-0.5">
|
||||
<span class="font-bold text-[#15171a]">대표 이미지 표시</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-post-form__featured-image-display-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
v-model="props.form.showFeaturedImage"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="대표 이미지 표시"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__featured-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<span class="admin-post-form__featured-toggle-copy flex min-w-0 items-center gap-3">
|
||||
<span class="admin-post-form__featured-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
|
||||
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3.5l2.7 5.47 6.04.88-4.37 4.26 1.03 6.01L12 17.28l-5.4 2.84 1.03-6.01-4.37-4.26 6.04-.88L12 3.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="admin-post-form__featured-toggle-label font-bold text-[#15171a]">
|
||||
추천 글
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-post-form__featured-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
v-model="props.form.isFeatured"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="추천 글로 표시"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__search-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<span class="admin-post-form__search-toggle-copy flex min-w-0 items-center gap-3">
|
||||
<span class="admin-post-form__search-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M480-100q-78.77 0-148.11-29.96-69.35-29.96-120.66-81.27-51.31-51.31-81.27-120.66Q100-401.23 100-480q0-78.77 29.96-148.11 29.96-69.35 81.27-120.66 51.31-51.31 120.66-81.27Q401.23-860 480-860q142.92 0 248.38 91.89 105.47 91.88 126.31 228.73h-61.23q-14.77-79.93-65.61-143.2Q677-745.85 600-776.77V-760q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h76.92v120H360L168-552q-3 18-5.5 36t-2.5 36q0 131 92 225t228 95v60Zm362.46-15.39-133-132.23q-19.46 12.39-41.92 20Q645.08-220 620-220q-66.92 0-113.46-46.54Q460-313.08 460-380q0-66.92 46.54-113.46Q553.08-540 620-540q66.92 0 113.46 46.54Q780-446.92 780-380q0 25.46-7.81 48.12-7.81 22.65-20.58 42.11l133 132.23-42.15 42.15ZM691-309q29-29 29-71t-29-71q-29-29-71-29t-71 29q-29 29-29 71t29 71q29 29 71 29t71-29Z"/></svg>
|
||||
</span>
|
||||
<span class="admin-post-form__search-toggle-label font-bold text-[#15171a]">
|
||||
검색엔진 노출
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-post-form__search-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
v-model="searchEngineVisible"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="검색엔진에 노출"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-settings-sidebar__bottom admin-post-form__settings-bottom grid shrink-0 gap-4 border-t border-[#e3e6e8] px-8 py-6">
|
||||
<section class="admin-post-form__writing-stats grid gap-2 text-xs text-[#657080]" aria-label="본문 통계">
|
||||
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
|
||||
<span class="admin-post-form__writing-stats-label">단어</span>
|
||||
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">{{ props.formatStatNumber(props.postWritingStats.words) }}개</span>
|
||||
</div>
|
||||
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
|
||||
<span class="admin-post-form__writing-stats-label">문자</span>
|
||||
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">{{ props.formatStatNumber(props.postWritingStats.characters) }}개 <span class="text-[#8e9cac]">({{ props.formatStatNumber(props.postWritingStats.spaces) }} 공백)</span></span>
|
||||
</div>
|
||||
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
|
||||
<span class="admin-post-form__writing-stats-label">읽기 시간</span>
|
||||
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">{{ props.postWritingStats.readingMinutes ? `약 ${props.formatStatNumber(props.postWritingStats.readingMinutes)}분` : '0분' }}</span>
|
||||
</div>
|
||||
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
|
||||
<span class="admin-post-form__writing-stats-label">구성</span>
|
||||
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">블록 {{ props.formatStatNumber(props.postWritingStats.blocks) }}개 · 이미지 {{ props.formatStatNumber(props.postWritingStats.images) }}개</span>
|
||||
</div>
|
||||
</section>
|
||||
<button
|
||||
v-if="props.showDelete"
|
||||
class="admin-post-form__delete-post flex h-10 w-full items-center justify-center gap-2 rounded border border-[#d7dde2] bg-white text-sm font-bold text-[#394047] transition-colors hover:border-[#d21a26] hover:bg-red-50 hover:text-[#d21a26] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="props.deleting"
|
||||
@click="emit('delete-post')"
|
||||
>
|
||||
<svg
|
||||
class="admin-post-form__delete-post-icon size-5 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17.923 22.308H6.077a1.692 1.692 0 01-1.692-1.693V5.385h15.23v15.23a1.692 1.692 0 01-1.692 1.693zM9.462 17.23v-6.769M14.539 17.23v-6.769M1 5.385h22M14.539 2H9.462a1.692 1.692 0 00-1.693 1.692v1.693h8.462V3.692A1.692 1.692 0 0014.538 2z"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ props.deleting ? '삭제 중' : 'Delete post' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminEditorBlockPanel
|
||||
:open="props.editorBlockPanel.open"
|
||||
:panel="props.editorBlockPanel.panel"
|
||||
@panel-focus-in="emit('panel-focus-in')"
|
||||
@panel-focus-out="emit('panel-focus-out')"
|
||||
@update-media-image="(...args) => emit('update-media-image', ...args)"
|
||||
@set-media-use-alt="(...args) => emit('set-media-use-alt', ...args)"
|
||||
@move-gallery-image="(...args) => emit('move-gallery-image', ...args)"
|
||||
@remove-media-image="emit('remove-media-image', $event)"
|
||||
@add-gallery-images="emit('add-gallery-images', $event)"
|
||||
@update-embed-url="emit('update-embed-url', $event)"
|
||||
@update-quote-background="emit('update-quote-background', $event)"
|
||||
@update-callout-options="emit('update-callout-options', $event)"
|
||||
@update-code-options="emit('update-code-options', $event)"
|
||||
@update-toggle-options="emit('update-toggle-options', $event)"
|
||||
@update-inline-color="emit('update-inline-color', $event)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
152
components/admin/AdminRealtimePages.vue
Normal file
152
components/admin/AdminRealtimePages.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { formatAnalyticsCount, getActivePageHref, getActivePageTitle, getRealtimePages } from '~/lib/analytics-display.js'
|
||||
|
||||
const props = defineProps({ session: { type: Object, required: true } })
|
||||
const pages = computed(() => getRealtimePages(props.session))
|
||||
const triggerRef = ref(null)
|
||||
const panelRef = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const isPinned = ref(false)
|
||||
const popoverStyle = ref({})
|
||||
const popoverId = useId()
|
||||
let closeTimer = null
|
||||
|
||||
/**
|
||||
* 열린 페이지 목록이 화면 안에 표시되도록 위치를 계산한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const updatePosition = () => {
|
||||
if (!triggerRef.value || !isOpen.value) return
|
||||
const rect = triggerRef.value.getBoundingClientRect()
|
||||
const width = Math.min(480, window.innerWidth - 24)
|
||||
const maxHeight = Math.min(320, window.innerHeight - 24)
|
||||
const height = Math.min(panelRef.value?.parentElement?.offsetHeight || maxHeight, maxHeight)
|
||||
const top = rect.bottom + height + 8 <= window.innerHeight ? rect.bottom + 4 : Math.max(12, rect.top - height - 4)
|
||||
popoverStyle.value = {
|
||||
left: `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`,
|
||||
top: `${top}px`, width: `${width}px`, maxHeight: `${maxHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 닫기 예약을 취소한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const cancelClose = () => { clearTimeout(closeTimer) }
|
||||
|
||||
/**
|
||||
* 열린 페이지 목록을 표시한다.
|
||||
* @returns {Promise<void>} 위치 반영
|
||||
*/
|
||||
const openPages = async () => {
|
||||
if (pages.value.length <= 1) return
|
||||
cancelClose()
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}
|
||||
|
||||
/**
|
||||
* 고정하지 않은 목록을 포인터 이동 후 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const scheduleClose = () => {
|
||||
cancelClose()
|
||||
closeTimer = setTimeout(() => {
|
||||
if (!isPinned.value && !panelRef.value?.contains(document.activeElement) && document.activeElement !== triggerRef.value) isOpen.value = false
|
||||
}, 150)
|
||||
}
|
||||
|
||||
/**
|
||||
* 클릭이나 Enter로 목록 고정을 전환한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const togglePinned = () => {
|
||||
isPinned.value = !isPinned.value
|
||||
if (isPinned.value) openPages()
|
||||
else isOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록을 닫고 필요하면 트리거로 포커스를 돌린다.
|
||||
* @param {boolean} [restoreFocus] - 포커스 복원 여부
|
||||
* @returns {void}
|
||||
*/
|
||||
const closePages = (restoreFocus = false) => {
|
||||
isPinned.value = false
|
||||
if (restoreFocus) triggerRef.value?.focus()
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 바깥 클릭을 처리한다.
|
||||
* @param {PointerEvent} event - 포인터 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleOutside = (event) => {
|
||||
if (!triggerRef.value?.contains(event.target) && !panelRef.value?.contains(event.target)) closePages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape로 페이지 목록을 닫는다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleKeydown = (event) => {
|
||||
if (!isOpen.value) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closePages(true)
|
||||
}
|
||||
if (event.key === 'ArrowDown' && document.activeElement === triggerRef.value) {
|
||||
event.preventDefault()
|
||||
panelRef.value?.querySelector('a, button')?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => pages.value.length, (count) => {
|
||||
if (count <= 1) closePages()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', handleOutside)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
cancelClose()
|
||||
document.removeEventListener('pointerdown', handleOutside)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-realtime-pages min-w-0">
|
||||
<button v-if="pages.length > 1" ref="triggerRef" type="button" class="admin-realtime-pages__trigger flex w-full min-w-0 items-center gap-1 text-left text-xs text-muted hover:text-ink" :aria-expanded="isOpen" :aria-controls="popoverId" aria-label="열린 페이지 목록" @mouseenter="openPages" @mouseleave="scheduleClose" @focus="openPages" @blur="scheduleClose" @click="togglePinned">
|
||||
<span class="admin-realtime-pages__latest min-w-0 truncate">{{ getActivePageTitle(pages[0]) }}</span>
|
||||
<span v-if="pages.length > 1" class="admin-realtime-pages__extra shrink-0 font-medium text-[#2563eb]">외 <strong class="admin-realtime-pages__extra-number font-bold">{{ formatAnalyticsCount(pages.length - 1) }}</strong>개</span>
|
||||
</button>
|
||||
<a v-else-if="getActivePageHref(pages[0])" :href="getActivePageHref(pages[0])" target="_blank" rel="noopener noreferrer" class="admin-realtime-pages__single block truncate text-xs text-muted hover:text-ink hover:underline">{{ getActivePageTitle(pages[0]) }}</a>
|
||||
<span v-else class="admin-realtime-pages__single block truncate text-xs text-muted">{{ getActivePageTitle(pages[0]) }}</span>
|
||||
<Teleport to="body">
|
||||
<AdminPopoverSurface v-if="isOpen && pages.length > 1" :id="popoverId" root-class="admin-realtime-pages__popover admin-layout--light-controls fixed z-[80] overflow-y-auto rounded-lg border-line p-3 text-xs text-[#15171a]" :surface-style="popoverStyle" @mouseenter="cancelClose" @mouseleave="scheduleClose" @focusin="cancelClose" @focusout="scheduleClose">
|
||||
<div ref="panelRef">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<p class="font-semibold">열린 페이지 {{ formatAnalyticsCount(pages.length) }}개 · 최근 확인순</p>
|
||||
<button type="button" class="shrink-0 text-muted hover:text-ink" aria-label="열린 페이지 목록 닫기" @click="closePages(true)">닫기</button>
|
||||
</div>
|
||||
<p class="mb-2 text-muted">IP {{ session.ipAddress || '-' }}</p>
|
||||
<ul class="admin-realtime-pages__list divide-y divide-line">
|
||||
<li v-for="(page, index) in pages" :key="page.key || index" class="py-2">
|
||||
<a v-if="getActivePageHref(page)" :href="getActivePageHref(page)" target="_blank" rel="noopener noreferrer" class="admin-realtime-pages__link block break-words hover:underline">{{ getActivePageTitle(page) }} <span aria-hidden="true">↗</span><span class="sr-only"> 새 창으로 열기</span></a>
|
||||
<span v-else class="break-words">{{ getActivePageTitle(page) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AdminPopoverSurface>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
194
components/admin/AdminRowMoreMenu.vue
Normal file
194
components/admin/AdminRowMoreMenu.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<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">
|
||||
<AdminPopoverSurface
|
||||
v-if="isOpen"
|
||||
root-class="admin-row-more-menu__popover fixed z-[80] rounded-xl border-[#e2e5e9] py-2 text-sm text-[#3f4650]"
|
||||
:surface-style="popoverStyle"
|
||||
clipped
|
||||
role="menu"
|
||||
data-admin-row-menu
|
||||
>
|
||||
<slot />
|
||||
</AdminPopoverSurface>
|
||||
</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>
|
||||
80
components/admin/AdminSearchInput.vue
Normal file
80
components/admin/AdminSearchInput.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 검색 입력 표시값 */
|
||||
value: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 검색 입력 placeholder */
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '검색'
|
||||
},
|
||||
/** 스크린 리더용 입력 라벨 */
|
||||
label: {
|
||||
type: String,
|
||||
default: '검색'
|
||||
},
|
||||
/** label 루트에 추가할 화면별 클래스 */
|
||||
rootClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** input에 추가할 화면별 클래스 */
|
||||
inputClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** input 너비 클래스 */
|
||||
widthClass: {
|
||||
type: String,
|
||||
default: 'w-52'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['input', 'compositionupdate', 'compositionend'])
|
||||
const inputRef = ref(null)
|
||||
|
||||
/**
|
||||
* 검색 입력에 포커스를 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const focus = () => {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 입력 텍스트를 선택한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const select = () => {
|
||||
inputRef.value?.select()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
select
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="admin-search-input relative block" :class="rootClass">
|
||||
<span class="sr-only">{{ label }}</span>
|
||||
<svg class="admin-search-input__icon pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m21 21-4.34-4.34" />
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
</svg>
|
||||
<input
|
||||
ref="inputRef"
|
||||
:value="props.value"
|
||||
class="admin-search-input__field h-10 rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac]"
|
||||
:class="[widthClass, inputClass]"
|
||||
type="search"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="label"
|
||||
@input="emit('input', $event)"
|
||||
@compositionupdate="emit('compositionupdate', $event)"
|
||||
@compositionend="emit('compositionend', $event)"
|
||||
>
|
||||
</label>
|
||||
</template>
|
||||
76
components/admin/AdminSelectInput.vue
Normal file
76
components/admin/AdminSelectInput.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 선택 입력 값 */
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
/** 선택 옵션 목록 */
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
/** 스크린 리더용 선택 라벨 */
|
||||
label: {
|
||||
type: String,
|
||||
default: '선택'
|
||||
},
|
||||
/** label 루트에 추가할 화면별 클래스 */
|
||||
rootClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** select에 추가할 화면별 클래스 */
|
||||
selectClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** select 너비 클래스 */
|
||||
widthClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/**
|
||||
* option에 전달할 실제 값을 반환한다.
|
||||
* @param {Object} option - 선택 옵션
|
||||
* @returns {string|number} 선택 값
|
||||
*/
|
||||
const getOptionValue = (option) => option.value
|
||||
|
||||
/**
|
||||
* 선택 값을 상위 컴포넌트에 전달한다.
|
||||
* @param {Event} event - 변경 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateValue = (event) => {
|
||||
const selectedValue = event.target?.value || ''
|
||||
const selectedOption = props.options.find((option) => String(option.value) === selectedValue)
|
||||
emit('update:modelValue', selectedOption ? selectedOption.value : selectedValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="admin-select-input" :class="rootClass">
|
||||
<span class="sr-only">{{ label }}</span>
|
||||
<span class="admin-select-input__wrap relative block">
|
||||
<select
|
||||
:value="props.modelValue"
|
||||
class="admin-select-input__field h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
|
||||
:class="[widthClass, selectClass]"
|
||||
:aria-label="label"
|
||||
@change="updateValue"
|
||||
>
|
||||
<option v-for="option in options" :key="option.value" :value="getOptionValue(option)">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<svg class="admin-select-input__chevron pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
|
||||
</label>
|
||||
</template>
|
||||
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>
|
||||
119
components/admin/AdminSettingsNavigation.vue
Normal file
119
components/admin/AdminSettingsNavigation.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 관리자 설정 화면 내비게이션
|
||||
* @property {string} searchValue - 검색 입력 표시값
|
||||
* @property {Array} filteredGroups - 검색 결과가 반영된 내비 그룹
|
||||
* @property {Array} groups - 전체 내비 그룹
|
||||
* @property {string} activeSectionId - 현재 활성 섹션 id
|
||||
*/
|
||||
defineProps({
|
||||
searchValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
filteredGroups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activeSectionId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'search-input',
|
||||
'search-composition-end',
|
||||
'navigate'
|
||||
])
|
||||
|
||||
/**
|
||||
* 모바일 구역 선택 변경을 부모 내비게이션 요청으로 전달한다.
|
||||
* @param {Event} event - change 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onMobileNavChange = (event) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLSelectElement) || !target.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('navigate', target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="admin-settings-navigation admin-settings-screen__nav-column w-full shrink-0 border-b border-[#e6e8eb] bg-[#f7f8fa] lg:w-72 lg:max-w-[320px] lg:flex-none lg:border-b-0 lg:border-r lg:border-[#e6e8eb]">
|
||||
<div class="admin-settings-navigation__inner admin-settings-screen__nav-inner max-h-[40vh] overflow-y-auto p-4 lg:max-h-none lg:h-full lg:overflow-y-auto lg:p-5">
|
||||
<label class="admin-settings-navigation__search admin-settings-screen__search relative mb-4 block">
|
||||
<span class="sr-only">설정 검색</span>
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9aa3ad]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M16 16l4.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
:value="searchValue"
|
||||
class="admin-settings-navigation__search-input admin-settings-screen__search-input w-full rounded-md border border-[#dce0e5] bg-white py-2 pr-3 pl-9 text-sm text-[#15171a] placeholder:text-[#9aa3ad] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
placeholder="설정 검색"
|
||||
@input="$emit('search-input', $event)"
|
||||
@compositionupdate="$emit('search-input', $event)"
|
||||
@compositionend="$emit('search-composition-end', $event)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="admin-settings-navigation__empty admin-settings-screen__nav-empty rounded-md border border-dashed border-[#dce0e5] px-3 py-4 text-center text-xs text-[#657080]"
|
||||
>
|
||||
검색과 일치하는 설정 항목이 없습니다.
|
||||
</p>
|
||||
<nav v-else class="admin-settings-navigation__list admin-settings-screen__nav grid gap-5 text-sm" aria-label="설정 구역">
|
||||
<template v-for="(group, gi) in filteredGroups" :key="group.heading">
|
||||
<div v-if="gi > 0" class="admin-settings-navigation__separator admin-settings-screen__nav-separator h-px bg-[#dce0e5]" aria-hidden="true" />
|
||||
<div class="admin-settings-navigation__group admin-settings-screen__nav-group">
|
||||
<p class="admin-settings-navigation__heading admin-settings-screen__nav-heading mb-2 px-2 text-xs font-semibold tracking-wide text-[#9aa3ad] uppercase">
|
||||
{{ group.heading }}
|
||||
</p>
|
||||
<ul class="admin-settings-navigation__items admin-settings-screen__nav-list grid gap-0.5">
|
||||
<li v-for="item in group.items" :key="item.id">
|
||||
<button
|
||||
class="admin-settings-navigation__item admin-settings-screen__nav-item flex w-full items-center gap-2 rounded-md px-2 py-2 text-left font-medium text-[#5d6673] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
:class="activeSectionId === item.id ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||
type="button"
|
||||
@click="$emit('navigate', item.id)"
|
||||
>
|
||||
<AdminSettingsNavIcon :icon-id="item.iconId" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<label class="admin-settings-navigation__jump admin-settings-screen__nav-jump mt-4 grid gap-1 lg:hidden">
|
||||
<span class="mb-1 block text-xs font-medium text-[#657080]">구역 이동</span>
|
||||
<span class="relative">
|
||||
<select
|
||||
class="w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-2 py-2 pr-10 text-sm"
|
||||
:value="activeSectionId"
|
||||
@change="onMobileNavChange"
|
||||
>
|
||||
<optgroup v-for="group in groups" :key="group.heading" :label="group.heading">
|
||||
<option v-for="item in group.items" :key="item.id" :value="item.id">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
</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>
|
||||
196
components/admin/AdminSiteInfoSettingsCard.vue
Normal file
196
components/admin/AdminSiteInfoSettingsCard.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script setup>
|
||||
const logoInputRef = ref(null)
|
||||
|
||||
/**
|
||||
* 사이트 정보 설정 카드
|
||||
* @property {Object} form - 사이트 설정 폼 객체
|
||||
* @property {boolean} editing - 편집 모드 여부
|
||||
* @property {boolean} saving - 저장 중 여부
|
||||
* @property {boolean} uploadingLogo - 로고 업로드 중 여부
|
||||
* @property {boolean} hasChanges - 변경 여부
|
||||
*/
|
||||
defineProps({
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
uploadingLogo: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasChanges: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['begin', 'cancel', 'save', 'upload-logo'])
|
||||
|
||||
/**
|
||||
* 로고 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openLogoFilePicker = () => {
|
||||
logoInputRef.value?.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-misc"
|
||||
class="admin-site-info-settings-card admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<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]"
|
||||
>
|
||||
로고, 공개 URL, 푸터 저작권 문구를 관리합니다.
|
||||
</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-info-settings-card__readonly admin-settings-screen__misc-readonly grid gap-5 pt-5"
|
||||
>
|
||||
<div class="flex flex-col gap-5 md:flex-row md:items-start">
|
||||
<div
|
||||
class="admin-settings-screen__logo-preview grid h-16 w-16 shrink-0 place-items-center overflow-hidden rounded-2xl bg-[#f7f8fa]"
|
||||
:class="form.logoUrl ? 'border border-[#e6e8eb]' : 'border border-dashed border-[#cfd6df]'"
|
||||
>
|
||||
<img
|
||||
v-if="form.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
:src="form.logoUrl"
|
||||
alt="사이트 로고"
|
||||
>
|
||||
<span v-else class="text-sm font-semibold text-[#8b95a1]">
|
||||
미등록
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-settings-screen__readonly-list grid min-w-0 flex-1 gap-4 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">
|
||||
<h3 class="text-sm font-normal text-[#3f4650]">
|
||||
사이트 URL
|
||||
</h3>
|
||||
<p class="min-w-0 break-all text-sm font-normal leading-relaxed text-[#15171a]">
|
||||
{{ form.siteUrl || '—' }}
|
||||
</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">
|
||||
<h3 class="text-sm font-normal text-[#3f4650]">
|
||||
저작권 문구
|
||||
</h3>
|
||||
<p class="min-w-0 whitespace-pre-wrap text-sm font-normal leading-relaxed text-[#15171a]">
|
||||
{{ form.copyrightText || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-site-info-settings-card__edit admin-settings-screen__card-body grid gap-6">
|
||||
<div class="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="grid h-20 w-20 shrink-0 place-items-center overflow-hidden rounded-2xl border border-[#e6e8eb] bg-[#f7f8fa]">
|
||||
<img
|
||||
v-if="form.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
:src="form.logoUrl"
|
||||
alt="사이트 로고"
|
||||
>
|
||||
<span v-else class="text-2xl font-semibold text-[#9aa3ad]">
|
||||
{{ form.logoText || '井' }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[#15171a]">
|
||||
로고
|
||||
</h3>
|
||||
<p class="mt-1 max-w-md text-sm leading-relaxed text-[#657080]">
|
||||
1:1 비율 이미지로 등록합니다. 같은 이미지가 공개 로고와 파비콘으로 함께 사용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="h-10 shrink-0 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingLogo"
|
||||
@click="openLogoFilePicker"
|
||||
>
|
||||
{{ uploadingLogo ? '업로드 중' : form.logoUrl ? '로고 변경' : '로고 등록' }}
|
||||
</button>
|
||||
<input
|
||||
ref="logoInputRef"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:disabled="uploadingLogo"
|
||||
@change="$emit('upload-logo', $event)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">사이트 URL</span>
|
||||
<input
|
||||
v-model="form.siteUrl"
|
||||
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="url"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">저작권 문구</span>
|
||||
<input
|
||||
v-model="form.copyrightText"
|
||||
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
141
components/admin/AdminSlashCommandIcon.vue
Normal file
141
components/admin/AdminSlashCommandIcon.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 슬래시 명령 메뉴 아이콘 (Ghost 스타일 라인 아이콘)
|
||||
*/
|
||||
const props = defineProps({
|
||||
/** @type {import('vue').PropType<string>} */
|
||||
commandId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="admin-slash-command-icon"
|
||||
:class="`admin-slash-command-icon--${commandId}`"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- image -->
|
||||
<template v-if="commandId === 'image'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="m19.642 16.276-3.85-7a.517.517 0 0 0-.181-.189.585.585 0 0 0-.749.115l-4.533 5.494-2.307-2.516a.548.548 0 0 0-.206-.14.598.598 0 0 0-.499.031.529.529 0 0 0-.183.164l-2.75 4a.468.468 0 0 0-.015.507.526.526 0 0 0 .202.189c.084.045.18.069.28.069H19.15a.594.594 0 0 0 .268-.063.532.532 0 0 0 .2-.174.462.462 0 0 0 .024-.487ZM9.25 9c.911 0 1.65-.672 1.65-1.5S10.161 6 9.25 6c-.91 0-1.65.672-1.65 1.5S8.34 9 9.25 9Z"></path>
|
||||
</template>
|
||||
|
||||
<!-- gallery -->
|
||||
<template v-else-if="commandId === 'gallery'">
|
||||
<g clip-path="url(#a)"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 5H3.2C1.985 5 1 5.806 1 6.8v14.4c0 .994.985 1.8 2.2 1.8h17.6c1.215 0 2.2-.806 2.2-1.8V6.8c0-.994-.985-1.8-2.2-1.8ZM6 1h12"></path><path fill="currentColor" d="M15.142 10.264a.75.75 0 0 1 .529.4l4 8A.75.75 0 0 1 19 19.75H6a.75.75 0 0 1-.498-1.31l9-8a.75.75 0 0 1 .64-.176ZM7 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"></path></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h24v24H0z"></path></clipPath></defs>
|
||||
</template>
|
||||
|
||||
<!-- h1 -->
|
||||
<template v-else-if="commandId === 'h1'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M17.926 6.578v10.898c0 .602.33.963.862.963.541 0 .862-.351.862-.963V5.726c0-.682-.451-1.153-1.093-1.153-.39 0-.742.15-1.373.622l-2.026 1.504c-.4.29-.591.561-.591.852 0 .38.3.692.672.692.22 0 .43-.08.721-.291l1.885-1.374zM4.42 4.903a.77.77 0 0 1 .77.77v5.35h6.168v-5.35a.77.77 0 1 1 1.54 0v12.242a.77.77 0 0 1-1.54 0v-5.351H5.19v5.351a.77.77 0 1 1-1.54 0V5.673a.77.77 0 0 1 .77-.77"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- h2 -->
|
||||
<template v-else-if="commandId === 'h2'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M14.159 16c-.562.638-.725.905-.725 1.248 0 .553.439.886 1.135.886h6.289c.524 0 .829-.276.829-.724 0-.457-.324-.734-.83-.734H15.57v-.114l3.526-4.031c1.715-1.953 2.201-2.859 2.201-4.098 0-2.096-1.648-3.583-3.993-3.583-2.515 0-4.088 1.697-4.088 3.317 0 .514.305.867.772.867.39 0 .658-.258.791-.763.286-1.238 1.191-1.972 2.42-1.972 1.449 0 2.411.896 2.411 2.24 0 .895-.41 1.695-1.486 2.925zM3.419 5.364c.404 0 .731.327.731.731v5.087h5.863V6.095a.732.732 0 1 1 1.464 0v11.637a.732.732 0 0 1-1.464 0v-5.087H4.15v5.087a.732.732 0 1 1-1.463 0V6.095c0-.404.327-.731.732-.731"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- h3 -->
|
||||
<template v-else-if="commandId === 'h3'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4.05 5.856a.75.75 0 0 0-1.5 0v11.921a.75.75 0 1 0 1.5 0v-5.21h6.006v5.21a.75.75 0 0 0 1.5 0V5.856a.75.75 0 0 0-1.5 0v5.21H4.05zm9.479 9.234c-.418 0-.713.304-.713.732 0 1.454 1.796 2.936 4.248 2.936 2.642 0 4.486-1.558 4.486-3.782 0-1.635-1.226-3.041-2.832-3.222v-.095c1.321-.228 2.395-1.596 2.395-3.031 0-1.977-1.692-3.393-4.068-3.393-2.338 0-3.925 1.425-3.925 2.898 0 .476.285.79.723.79.37 0 .608-.2.798-.723.38-.979 1.235-1.54 2.366-1.54 1.454 0 2.433.875 2.433 2.177s-1.007 2.242-2.395 2.242h-1.121c-.456 0-.76.295-.76.713 0 .409.323.723.76.723h1.188c1.654 0 2.765.978 2.765 2.432s-1.083 2.386-2.784 2.386c-1.293 0-2.281-.57-2.775-1.587-.247-.485-.456-.656-.789-.656"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- h4 -->
|
||||
<template v-else-if="commandId === 'h4'">
|
||||
<path fill="currentColor" d="M140-290v-380h60v160h180v-160h60v380h-60v-160H200v160h-60Zm580 0v-120H520v-260h60v200h140v-200h60v200h80v60h-80v120h-60Z"/>
|
||||
</template>
|
||||
|
||||
<!-- quote -->
|
||||
<template v-else-if="commandId === 'quote'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M5 10.966v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024H6.704A3.35 3.35 0 0 1 9.845 7.75v-1.5A4.845 4.845 0 0 0 5 10.966m8 0v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024h-3.415a3.35 3.35 0 0 1 3.141-2.192v-1.5A4.845 4.845 0 0 0 13 10.966"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- list -->
|
||||
<template v-else-if="commandId === 'list'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4.3 7.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2m4.033-1.75a.75.75 0 0 0 0 1.5l11.917.001a.75.75 0 0 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 1 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 0 0 0-1.5zM5.3 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1 6.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- table -->
|
||||
<template v-else-if="commandId === 'table'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 5.5h16M4 11.5h16M4 17.5h16M8.5 5.5v12M15.5 5.5v12" />
|
||||
<rect x="3" y="4" width="18" height="15" rx="1.5" stroke="currentColor" stroke-width="1.8" />
|
||||
</template>
|
||||
|
||||
<!-- code -->
|
||||
<template v-else-if="commandId === 'code'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 6A2.75 2.75 0 0 1 5 3.25h14A2.75 2.75 0 0 1 21.75 6v12A2.75 2.75 0 0 1 19 20.75H5A2.75 2.75 0 0 1 2.25 18zM5 4.75c-.69 0-1.25.56-1.25 1.25v12c0 .69.56 1.25 1.25 1.25h14c.69 0 1.25-.56 1.25-1.25V6c0-.69-.56-1.25-1.25-1.25zm5.53 4.62a.75.75 0 0 1 0 1.06l-1.59 1.591 1.59 1.591a.75.75 0 0 1-1.06 1.06l-2.122-2.12a.75.75 0 0 1 0-1.061L9.47 9.37a.75.75 0 0 1 1.06 0m2.94 1.06a.75.75 0 1 1 1.06-1.06l2.122 2.12a.75.75 0 0 1 0 1.062l-2.122 2.12a.75.75 0 1 1-1.06-1.06l1.59-1.59z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- divider -->
|
||||
<template v-else-if="commandId === 'divider'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4 4.25a.75.75 0 0 1 .75.75v1c0 .69.56 1.25 1.25 1.25h12c.69 0 1.25-.56 1.25-1.25V5a.75.75 0 0 1 1.5 0v1A2.75 2.75 0 0 1 18 8.75H6A2.75 2.75 0 0 1 3.25 6V5A.75.75 0 0 1 4 4.25m0 15.5a.75.75 0 0 0 .75-.75v-1c0-.69.56-1.25 1.25-1.25h12c.69 0 1.25.56 1.25 1.25v1a.75.75 0 0 0 1.5 0v-1A2.75 2.75 0 0 0 18 15.25H6A2.75 2.75 0 0 0 3.25 18v1c0 .414.336.75.75.75m-1-8.5a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5H3m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5H7.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5h-1.2m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5h-1.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5H21a.75.75 0 0 0 0-1.5h-1.2"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- callout -->
|
||||
<template v-else-if="commandId === 'callout'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="M12 18a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.5v6"></path>
|
||||
</template>
|
||||
|
||||
<!-- toggle -->
|
||||
<template v-else-if="commandId === 'toggle'">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 11 12 15l-4.5-4"></path>
|
||||
</template>
|
||||
|
||||
<!-- embed/html/link/iframe -->
|
||||
<template v-else-if="['html', 'link', 'iframe'].includes(commandId)">
|
||||
<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>
|
||||
214
components/admin/AdminSocialSettingsCard.vue
Normal file
214
components/admin/AdminSocialSettingsCard.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import {
|
||||
SOCIAL_ICON_PRESETS,
|
||||
getSocialIconPreset,
|
||||
normalizeSocialLinks
|
||||
} from '~/lib/social-links.js'
|
||||
|
||||
const socialIconSelectOptions = SOCIAL_ICON_PRESETS.map((preset) => ({
|
||||
value: preset.icon,
|
||||
label: preset.label
|
||||
}))
|
||||
|
||||
/**
|
||||
* SNS 설정 카드
|
||||
* @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'])
|
||||
|
||||
/**
|
||||
* SNS 링크 항목을 추가한다.
|
||||
* @param {Object} form - 사이트 설정 폼 객체
|
||||
* @returns {void}
|
||||
*/
|
||||
const addSocialLink = (form) => {
|
||||
form.socialLinks = [
|
||||
...normalizeSocialLinks(form.socialLinks),
|
||||
{
|
||||
id: `social-${Date.now()}`,
|
||||
icon: SOCIAL_ICON_PRESETS[0].icon,
|
||||
label: SOCIAL_ICON_PRESETS[0].label,
|
||||
url: '',
|
||||
iconSvg: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* SNS 링크 항목을 제거한다.
|
||||
* @param {Object} form - 사이트 설정 폼 객체
|
||||
* @param {number} index - 제거할 항목 순서
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeSocialLink = (form, index) => {
|
||||
form.socialLinks = form.socialLinks.filter((_, itemIndex) => itemIndex !== index)
|
||||
}
|
||||
|
||||
/**
|
||||
* SNS 링크 아이콘을 변경한다.
|
||||
* @param {Object} form - 사이트 설정 폼 객체
|
||||
* @param {number} index - 항목 순서
|
||||
* @param {string} icon - 아이콘 키
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateSocialLinkIcon = (form, index, icon) => {
|
||||
const preset = getSocialIconPreset(icon)
|
||||
form.socialLinks[index].icon = preset.icon
|
||||
form.socialLinks[index].label = preset.label
|
||||
|
||||
if (preset.icon !== 'custom') {
|
||||
form.socialLinks[index].iconSvg = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-social"
|
||||
class="admin-social-settings-card admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<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">
|
||||
SNS 정보
|
||||
</h2>
|
||||
<p
|
||||
v-if="!editing"
|
||||
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
|
||||
>
|
||||
오른쪽 사이드바 FOLLOW 영역에 표시할 아이콘과 주소를 관리합니다.
|
||||
</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-social-settings-card__readonly admin-settings-screen__social-readonly pt-5"
|
||||
>
|
||||
<div
|
||||
v-if="normalizeSocialLinks(form.socialLinks).length"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<span
|
||||
v-for="item in normalizeSocialLinks(form.socialLinks)"
|
||||
:key="item.id"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[#dce0e5] bg-[#f7f8fa] px-3 py-1.5 text-sm font-semibold text-[#15171a]"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="max-w-[220px] truncate text-xs font-medium text-[#657080]">{{ item.url }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="text-sm text-[#657080]">
|
||||
등록된 SNS 링크가 없습니다. 공개 화면의 FOLLOW 아이콘도 표시되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-social-settings-card__edit admin-settings-screen__social-edit grid gap-4 border-t border-[#eceff2] pt-5">
|
||||
<div
|
||||
v-for="(item, index) in form.socialLinks"
|
||||
:key="item.id || index"
|
||||
class="grid gap-3 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-3 md:grid-cols-[220px_minmax(0,1fr)_auto] md:items-start"
|
||||
>
|
||||
<div class="grid gap-1.5 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">아이콘</span>
|
||||
<AdminSelectInput
|
||||
:model-value="item.icon"
|
||||
:options="socialIconSelectOptions"
|
||||
label="SNS 아이콘"
|
||||
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
width-class="w-full"
|
||||
@update:model-value="(icon) => updateSocialLinkIcon(form, index, icon)"
|
||||
/>
|
||||
</div>
|
||||
<label class="grid gap-1.5 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">주소</span>
|
||||
<input
|
||||
v-model="item.url"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="text"
|
||||
:placeholder="getSocialIconPreset(item.icon).placeholder"
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
class="mt-[26px] h-10 rounded-md border border-[#ffd1d1] bg-white px-4 text-sm font-semibold text-[#d73939] transition-colors hover:bg-[#fff5f5]"
|
||||
type="button"
|
||||
@click="removeSocialLink(form, index)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<label
|
||||
v-if="item.icon === 'custom'"
|
||||
class="grid gap-1.5 text-sm md:col-span-3"
|
||||
>
|
||||
<span class="font-medium text-[#3f4650]">사용자 SVG 아이콘</span>
|
||||
<textarea
|
||||
v-model="item.iconSvg"
|
||||
class="min-h-24 rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-xs leading-relaxed text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
placeholder="<svg ...>...</svg>"
|
||||
/>
|
||||
<span class="text-xs leading-relaxed text-[#8b95a1]">
|
||||
새 SNS 아이콘은 SVG 코드를 붙여 넣어 사용할 수 있습니다. 저장 시 기본적인 위험 태그는 제거됩니다.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex h-10 w-fit items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7]"
|
||||
type="button"
|
||||
@click="addSocialLink(form)"
|
||||
>
|
||||
SNS 링크 추가
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
120
components/admin/AdminSpamSettingsCard.vue
Normal file
120
components/admin/AdminSpamSettingsCard.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 스팸 필터 설정 카드
|
||||
* @property {Array<string>} blockedUsernames - 가입 금지 닉네임 목록
|
||||
* @property {string} blockedUsernamesText - 가입 금지 닉네임 textarea 값
|
||||
* @property {boolean} editing - 편집 모드 여부
|
||||
* @property {boolean} saving - 저장 중 여부
|
||||
* @property {boolean} hasChanges - 변경 여부
|
||||
*/
|
||||
defineProps({
|
||||
blockedUsernames: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
blockedUsernamesText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasChanges: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['begin', 'cancel', 'save', 'update:blockedUsernamesText'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
id="admin-settings-section-spam"
|
||||
class="admin-spam-settings-card admin-settings-screen__card admin-settings-screen__card--spam 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]"
|
||||
>
|
||||
회원가입 시 사용할 수 없는 닉네임을 지정합니다. 닉네임에 해당 단어가 포함되면 가입이 거부됩니다.
|
||||
</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-spam-settings-card__readonly admin-settings-screen__spam-readonly border-t border-[#eceff2] pt-5 text-sm"
|
||||
>
|
||||
<p class="font-medium text-[#3f4650]">
|
||||
가입 금지 닉네임
|
||||
</p>
|
||||
<ul class="mt-3 grid gap-1.5 text-[#657080]">
|
||||
<li
|
||||
v-for="term in blockedUsernames"
|
||||
:key="term"
|
||||
class="font-mono text-[13px] text-[#15171a]"
|
||||
>
|
||||
{{ term }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="admin-spam-settings-card__edit admin-settings-screen__spam-edit grid gap-2 border-t border-[#eceff2] pt-5 text-sm"
|
||||
>
|
||||
<label class="admin-settings-screen__field grid gap-2">
|
||||
<span class="font-medium text-[#3f4650]">가입 금지 닉네임</span>
|
||||
<p class="text-xs leading-relaxed text-[#657080]">
|
||||
한 줄에 하나씩 입력합니다. 대소문자는 구분하지 않으며, 닉네임에 해당 단어가 포함되면 사용할 수 없습니다.
|
||||
</p>
|
||||
<textarea
|
||||
:value="blockedUsernamesText"
|
||||
class="min-h-[10rem] 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="8"
|
||||
placeholder="admin master zenn"
|
||||
@input="$emit('update:blockedUsernamesText', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -11,20 +11,95 @@ const props = defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
requireChanges: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
defaultTagType: {
|
||||
type: String,
|
||||
default: 'general'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const slugTouched = ref(Boolean(props.initialTag.slug))
|
||||
const coverInputRef = ref(null)
|
||||
const uploadingCover = ref(false)
|
||||
const coverErrorMessage = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: props.initialTag.name || '',
|
||||
slug: props.initialTag.slug || '',
|
||||
description: props.initialTag.description || '',
|
||||
headerImageUrl: props.initialTag.headerImageUrl || '',
|
||||
headerTitle: props.initialTag.headerTitle || '',
|
||||
headerText: props.initialTag.headerText || '',
|
||||
color: props.initialTag.color || '#15171a'
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그 입력값을 저장 비교용 형태로 정규화한다.
|
||||
* @param {Object} tag - 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 정규화된 태그 입력값
|
||||
*/
|
||||
const normalizeTagPayload = (tag) => ({
|
||||
name: String(tag.name || '').trim(),
|
||||
slug: toSlug(tag.slug || tag.name || ''),
|
||||
description: String(tag.description || '').trim(),
|
||||
headerImageUrl: String(tag.headerImageUrl || '').trim(),
|
||||
headerTitle: String(tag.headerTitle || '').trim(),
|
||||
headerText: String(tag.headerText || '').trim(),
|
||||
sortOrder: Number(tag.sortOrder ?? 0),
|
||||
color: String(tag.color || '#15171a'),
|
||||
tagType: String(tag.tagType || props.defaultTagType)
|
||||
})
|
||||
|
||||
/**
|
||||
* 현재 폼 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
|
||||
*/
|
||||
const currentPayload = computed(() => normalizeTagPayload({
|
||||
name: form.name,
|
||||
slug: form.slug || form.name,
|
||||
description: form.description,
|
||||
headerImageUrl: form.headerImageUrl,
|
||||
headerTitle: form.headerTitle,
|
||||
headerText: form.headerText,
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
}))
|
||||
|
||||
/**
|
||||
* 최초 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 최초 저장 입력값
|
||||
*/
|
||||
const initialPayload = computed(() => normalizeTagPayload({
|
||||
name: props.initialTag.name || '',
|
||||
slug: props.initialTag.slug || props.initialTag.name || '',
|
||||
description: props.initialTag.description || '',
|
||||
headerImageUrl: props.initialTag.headerImageUrl || '',
|
||||
headerTitle: props.initialTag.headerTitle || '',
|
||||
headerText: props.initialTag.headerText || '',
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: props.initialTag.color || '#15171a',
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
}))
|
||||
|
||||
/**
|
||||
* 태그 입력값 변경 여부
|
||||
* @returns {boolean} 변경 여부
|
||||
*/
|
||||
const hasChanges = computed(() => JSON.stringify(currentPayload.value) !== JSON.stringify(initialPayload.value))
|
||||
|
||||
/**
|
||||
* 태그 저장 가능 여부
|
||||
* @returns {boolean} 저장 가능 여부
|
||||
*/
|
||||
const canSubmit = computed(() => !props.saving && !uploadingCover.value && (!props.requireChanges || hasChanges.value))
|
||||
|
||||
/**
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
* @param {string} value - 원본 문자열
|
||||
@@ -53,19 +128,66 @@ const touchSlug = () => {
|
||||
form.slug = toSlug(form.slug)
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지 선택 창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openCoverPicker = () => {
|
||||
coverInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지를 제거한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeCoverImage = () => {
|
||||
form.headerImageUrl = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지를 업로드한다.
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>} 업로드 결과
|
||||
*/
|
||||
const uploadCoverImage = async (event) => {
|
||||
const input = event.target
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : null
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadingCover.value = true
|
||||
coverErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const result = await $fetch('/admin/api/tags/cover', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
form.headerImageUrl = result.headerImageUrl || ''
|
||||
} catch (error) {
|
||||
coverErrorMessage.value = error?.data?.message || '태그 헤더 이미지를 업로드하지 못했습니다.'
|
||||
} finally {
|
||||
uploadingCover.value = false
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 입력값 제출
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitTag = () => {
|
||||
emit('submit', {
|
||||
name: form.name.trim(),
|
||||
slug: toSlug(form.slug || form.name),
|
||||
description: form.description.trim(),
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: props.initialTag.tagType || 'general'
|
||||
})
|
||||
if (!canSubmit.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', currentPayload.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -121,6 +243,89 @@ const submitTag = () => {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="admin-tag-form__panel admin-tag-form__panel--header grid gap-5 border border-line bg-white p-5">
|
||||
<div class="admin-tag-form__header-cover-head flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="admin-tag-form__header-cover-copy grid gap-1">
|
||||
<h2 class="admin-tag-form__section-title text-base font-semibold">
|
||||
태그 헤더
|
||||
</h2>
|
||||
<p class="admin-tag-form__section-description text-sm text-muted">
|
||||
이미지가 있으면 공개 태그 페이지의 기본 제목과 설명 대신 헤더 이미지를 표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-tag-form__header-cover-actions flex shrink-0 gap-2">
|
||||
<button
|
||||
class="admin-tag-form__cover-button rounded border border-line bg-white px-3 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingCover"
|
||||
@click="openCoverPicker"
|
||||
>
|
||||
{{ uploadingCover ? '업로드 중' : '이미지 변경' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="form.headerImageUrl"
|
||||
class="admin-tag-form__cover-remove rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-muted disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingCover"
|
||||
@click="removeCoverImage"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.headerImageUrl"
|
||||
class="admin-tag-form__cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||
>
|
||||
<HomeHero
|
||||
:image-url="form.headerImageUrl"
|
||||
:title="form.headerTitle"
|
||||
:text="form.headerText"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="admin-tag-form__cover-dropzone grid aspect-[720/215] w-full max-w-full cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-[#f7f8fa] px-4 text-center text-sm text-muted transition hover:border-[#15171a] hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="uploadingCover"
|
||||
@click="openCoverPicker"
|
||||
>
|
||||
태그 헤더 이미지를 선택하세요.
|
||||
</button>
|
||||
<input
|
||||
ref="coverInputRef"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
@change="uploadCoverImage"
|
||||
>
|
||||
<p v-if="coverErrorMessage" class="admin-tag-form__cover-error rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{{ coverErrorMessage }}
|
||||
</p>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">오버레이 제목</span>
|
||||
<input
|
||||
v-model="form.headerTitle"
|
||||
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
placeholder="비워두면 이미지에 제목을 표시하지 않습니다."
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">오버레이 본문</span>
|
||||
<textarea
|
||||
v-model="form.headerText"
|
||||
class="admin-tag-form__textarea min-h-24 rounded border border-line bg-white px-3 py-2"
|
||||
maxlength="280"
|
||||
placeholder="비워두면 이미지에 본문을 표시하지 않습니다."
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="admin-tag-form__actions flex justify-end gap-3">
|
||||
<NuxtLink class="admin-tag-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/tags">
|
||||
취소
|
||||
@@ -128,7 +333,7 @@ const submitTag = () => {
|
||||
<button
|
||||
class="admin-tag-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
{{ saving ? '저장 중' : submitLabel }}
|
||||
</button>
|
||||
|
||||
146
components/admin/AdminTitleDescriptionSettingsCard.vue
Normal file
146
components/admin/AdminTitleDescriptionSettingsCard.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<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-title"
|
||||
data-testid="title-and-description"
|
||||
class="admin-title-description-settings-card admin-settings-screen__card admin-settings-screen__card--title-desc 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">
|
||||
<h5 class="text-base font-semibold text-[#15171a] md:text-lg">
|
||||
블로그 제목·설명
|
||||
</h5>
|
||||
<p
|
||||
v-if="!editing"
|
||||
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080] md:block"
|
||||
>
|
||||
공개 사이트에서 사이트 이름과 소개로 사용되는 값입니다.
|
||||
</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-title-description-settings-card__readonly admin-settings-screen__readonly-list 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">
|
||||
<h6 class="text-sm font-normal tracking-normal text-[#3f4650]">
|
||||
사이트 이름
|
||||
</h6>
|
||||
<div class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
|
||||
{{ form.title?.trim() ? form.title : '—' }}
|
||||
</div>
|
||||
</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">
|
||||
<h6 class="text-sm font-normal tracking-normal text-[#3f4650]">
|
||||
사이트 설명
|
||||
</h6>
|
||||
<div class="min-w-0 whitespace-pre-wrap text-sm font-normal leading-relaxed text-[#15171a]">
|
||||
{{ form.description?.trim() ? form.description : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-title-description-settings-card__edit admin-settings-screen__card-body grid gap-5">
|
||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">사이트 이름</span>
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||
<span class="font-medium text-[#3f4650]">사이트 설명</span>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="min-h-28 resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div class="rounded-lg border border-[#eceff2] bg-[#f7f8fa] p-4">
|
||||
<p class="text-xs font-semibold tracking-wide text-[#657080] uppercase">
|
||||
공개 화면 미리보기
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-[#15171a] text-lg font-bold text-white">
|
||||
<img
|
||||
v-if="form.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
:src="form.logoUrl"
|
||||
alt=""
|
||||
>
|
||||
<span v-else>{{ form.logoText || '井' }}</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-[#15171a]">
|
||||
{{ form.title || 'sori.studio' }}
|
||||
</p>
|
||||
<p class="truncate text-sm text-[#657080]">
|
||||
{{ form.description || '사이트 설명이 공개 화면에 표시됩니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
42
components/admin/AdminToast.vue
Normal file
42
components/admin/AdminToast.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 표시할 토스트 데이터 */
|
||||
toast: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
/** 화면별 상단 위치 클래스 */
|
||||
topClass: {
|
||||
type: String,
|
||||
default: 'top-5'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 토스트 타입별 스타일 클래스를 반환한다.
|
||||
* @param {string} type - 토스트 타입
|
||||
* @returns {string} 스타일 클래스
|
||||
*/
|
||||
const getToastToneClass = (type) => {
|
||||
if (type === 'success') {
|
||||
return 'border-green-200 bg-green-50 text-green-800'
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
return 'border-red-200 bg-red-50 text-red-800'
|
||||
}
|
||||
|
||||
return 'border-line bg-white text-ink'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="props.toast"
|
||||
class="admin-toast fixed right-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="[topClass, getToastToneClass(props.toast.type)]"
|
||||
role="status"
|
||||
>
|
||||
{{ props.toast.message }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,8 +17,11 @@ const replyErrorMessage = ref('')
|
||||
const newCommentBody = ref('')
|
||||
const replyBody = ref('')
|
||||
const activeReplyTargetId = ref('')
|
||||
const sortOption = ref('best')
|
||||
const sortOption = ref('latest')
|
||||
const brokenAvatarCommentIds = ref([])
|
||||
let commentsRequestId = 0
|
||||
const canSubmitComment = computed(() => Boolean(newCommentBody.value.trim()) && !submitting.value)
|
||||
const canSubmitReply = computed(() => Boolean(replyBody.value.trim()) && !submittingReplyId.value)
|
||||
|
||||
/**
|
||||
* 댓글 시간을 상대 시간 형식으로 변환한다.
|
||||
@@ -111,28 +114,59 @@ const markAvatarBroken = (commentId) => {
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
member.value = await $fetch('/api/auth/me?optional=1')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 변경 시 댓글 입력·답글·오류 상태를 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const resetCommentState = () => {
|
||||
commentsRequestId += 1
|
||||
comments.value = []
|
||||
loadingComments.value = false
|
||||
submitting.value = false
|
||||
submittingReplyId.value = ''
|
||||
likingCommentIds.value = []
|
||||
errorMessage.value = ''
|
||||
replyErrorMessage.value = ''
|
||||
newCommentBody.value = ''
|
||||
replyBody.value = ''
|
||||
activeReplyTargetId.value = ''
|
||||
sortOption.value = 'latest'
|
||||
brokenAvatarCommentIds.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 목록을 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchComments = async () => {
|
||||
const requestId = ++commentsRequestId
|
||||
const requestedSlug = props.slug
|
||||
loadingComments.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/posts/${props.slug}/comments`)
|
||||
const response = await $fetch(`/api/posts/${requestedSlug}/comments`)
|
||||
|
||||
if (requestId !== commentsRequestId || requestedSlug !== props.slug) {
|
||||
return
|
||||
}
|
||||
comments.value = response.comments || []
|
||||
} catch (error) {
|
||||
if (requestId !== commentsRequestId || requestedSlug !== props.slug) {
|
||||
return
|
||||
}
|
||||
comments.value = []
|
||||
errorMessage.value = error?.data?.message || '댓글을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loadingComments.value = false
|
||||
if (requestId === commentsRequestId && requestedSlug === props.slug) {
|
||||
loadingComments.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,22 +180,30 @@ const submitComment = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const requestedSlug = props.slug
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/posts/${props.slug}/comments`, {
|
||||
await $fetch(`/api/posts/${requestedSlug}/comments`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
body
|
||||
}
|
||||
})
|
||||
if (requestedSlug !== props.slug) {
|
||||
return
|
||||
}
|
||||
newCommentBody.value = ''
|
||||
await fetchComments()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.'
|
||||
if (requestedSlug === props.slug) {
|
||||
errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
if (requestedSlug === props.slug) {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,23 +239,31 @@ const submitReply = async (parentId) => {
|
||||
return
|
||||
}
|
||||
|
||||
const requestedSlug = props.slug
|
||||
submittingReplyId.value = parentId
|
||||
replyErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/posts/${props.slug}/comments`, {
|
||||
await $fetch(`/api/posts/${requestedSlug}/comments`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
body,
|
||||
parentId
|
||||
}
|
||||
})
|
||||
if (requestedSlug !== props.slug) {
|
||||
return
|
||||
}
|
||||
closeReplyForm()
|
||||
await fetchComments()
|
||||
} catch (error) {
|
||||
replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.'
|
||||
if (requestedSlug === props.slug) {
|
||||
replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.'
|
||||
}
|
||||
} finally {
|
||||
submittingReplyId.value = ''
|
||||
if (requestedSlug === props.slug) {
|
||||
submittingReplyId.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,14 +284,18 @@ const toggleLike = async (commentId) => {
|
||||
return
|
||||
}
|
||||
|
||||
const requestedSlug = props.slug
|
||||
likingCommentIds.value = [...likingCommentIds.value, commentId]
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/api/posts/${props.slug}/comments/${commentId}/like`, {
|
||||
const result = await $fetch(`/api/posts/${requestedSlug}/comments/${commentId}/like`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (requestedSlug !== props.slug) {
|
||||
return
|
||||
}
|
||||
comments.value = comments.value.map((item) => {
|
||||
if (item.id !== commentId) {
|
||||
return item
|
||||
@@ -253,9 +307,13 @@ const toggleLike = async (commentId) => {
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.'
|
||||
if (requestedSlug === props.slug) {
|
||||
errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.'
|
||||
}
|
||||
} finally {
|
||||
likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId)
|
||||
if (requestedSlug === props.slug) {
|
||||
likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +361,15 @@ const repliesByParent = computed(() => {
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchMember(), fetchComments()])
|
||||
})
|
||||
|
||||
watch(() => props.slug, async (nextSlug, previousSlug) => {
|
||||
if (!nextSlug || nextSlug === previousSlug) {
|
||||
return
|
||||
}
|
||||
|
||||
resetCommentState()
|
||||
await fetchComments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -313,15 +380,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="latest">최신순</option>
|
||||
<option value="best">인기순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -339,8 +406,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 +515,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 +531,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 +610,3 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
56
components/content/ContentMarkdownBlockGroup.vue
Normal file
56
components/content/ContentMarkdownBlockGroup.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script>
|
||||
import { h, mergeProps } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'ContentMarkdownBlockGroup',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
/** 라이브 편집 모드 여부 */
|
||||
interactive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 블록 앞 삽입 영역 표시 여부 */
|
||||
showInsert: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 블록 앞 삽입 영역 활성 상태 */
|
||||
insertActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
/**
|
||||
* 삽입 영역과 블록 본문을 조건부로 묶어 fragment 주석을 줄인다.
|
||||
* @returns {import('vue').VNode|import('vue').VNode[]} 렌더 노드
|
||||
*/
|
||||
return () => {
|
||||
const children = []
|
||||
|
||||
if (props.interactive && props.showInsert) {
|
||||
children.push(h('div', mergeProps({
|
||||
class: [
|
||||
'content-markdown-renderer__block-insert',
|
||||
props.insertActive ? 'content-markdown-renderer__block-insert--active' : ''
|
||||
]
|
||||
}, attrs)))
|
||||
}
|
||||
|
||||
const defaultContent = slots.default?.()
|
||||
|
||||
if (defaultContent) {
|
||||
const normalized = Array.isArray(defaultContent) ? defaultContent : [defaultContent]
|
||||
children.push(...normalized)
|
||||
}
|
||||
|
||||
if (children.length === 1) {
|
||||
return children[0]
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
129
components/content/ContentMarkdownCalloutEditor.vue
Normal file
129
components/content/ContentMarkdownCalloutEditor.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { buildCalloutOpenerLine } from '../../lib/markdown-callout.js'
|
||||
import ProseCallout from './ProseCallout.vue'
|
||||
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 콜아웃 본문 */
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
calloutEmojiEnabled: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
calloutEmoji: {
|
||||
type: String,
|
||||
default: '💡'
|
||||
},
|
||||
calloutTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
calloutBackground: {
|
||||
type: String,
|
||||
default: 'blue'
|
||||
},
|
||||
/** 본문 첫 줄 source-line(0-based) */
|
||||
bodySourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
/** 콜아웃 선언 줄 source-line(0-based) */
|
||||
blockSourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['commit', 'delete-line', 'insert-above', 'insert-below', 'merge-with-previous', 'leave-block', 'focus-line'])
|
||||
|
||||
const bodyLines = computed(() => {
|
||||
const lines = String(props.modelValue ?? '').replace(/\r/g, '').split('\n')
|
||||
return lines.length ? lines : ['']
|
||||
})
|
||||
|
||||
/**
|
||||
* 콜아웃 마크다운 줄을 반영한다.
|
||||
* @param {string[]} contentLines - 본문 줄
|
||||
* @returns {void}
|
||||
*/
|
||||
const commitCalloutLines = (contentLines) => {
|
||||
emit('commit', [
|
||||
buildCalloutOpenerLine({
|
||||
calloutEmojiEnabled: props.calloutEmojiEnabled,
|
||||
calloutEmoji: props.calloutEmoji,
|
||||
calloutBackground: props.calloutBackground,
|
||||
title: props.calloutTitle
|
||||
}),
|
||||
...contentLines,
|
||||
':::'
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜아웃 본문 문자열을 줄 목록으로 정규화한다.
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {string[]} 본문 줄
|
||||
*/
|
||||
const normalizeBodyLines = (payload) => {
|
||||
const value = typeof payload === 'string'
|
||||
? payload
|
||||
: String(payload?.value ?? '')
|
||||
|
||||
const lines = String(value ?? '').replace(/\r/g, '').split('\n')
|
||||
return lines.length ? lines : ['']
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 편집 반영
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyCommit = (payload) => {
|
||||
commitCalloutLines(normalizeBodyLines(payload))
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 입력 중 마크다운을 동기화한다.
|
||||
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyInput = (payload) => {
|
||||
commitCalloutLines(normalizeBodyLines(payload))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="content-markdown-callout-editor relative"
|
||||
:data-source-line="blockSourceLine"
|
||||
>
|
||||
<ProseCallout
|
||||
:emoji-enabled="calloutEmojiEnabled"
|
||||
:emoji="calloutEmoji"
|
||||
:background="calloutBackground"
|
||||
:title="calloutTitle"
|
||||
>
|
||||
<ContentMarkdownEditableInline
|
||||
block-class="content-markdown-callout-editor__body min-w-0 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
enter-mode="multiline"
|
||||
block-markdown
|
||||
arrow-exit-creates-line
|
||||
preserve-empty-line-on-full-delete
|
||||
:source-line="bodySourceLine"
|
||||
:source-line-count="bodyLines.length"
|
||||
:model-value="modelValue"
|
||||
@input="onBodyInput"
|
||||
@commit="onBodyCommit"
|
||||
@delete-line="emit('delete-line', $event)"
|
||||
@insert-above="emit('insert-above', $event)"
|
||||
@insert-below="emit('insert-below', $event)"
|
||||
@merge-with-previous="emit('merge-with-previous', bodySourceLine, $event)"
|
||||
@leave-block="emit('leave-block', $event)"
|
||||
@focus-line="emit('focus-line', $event)"
|
||||
/>
|
||||
</ProseCallout>
|
||||
</div>
|
||||
</template>
|
||||
193
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
193
components/content/ContentMarkdownCodeBlockEditor.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup>
|
||||
import { buildCodeBlockLines } from '../../lib/markdown-code-block.js'
|
||||
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 코드 본문 */
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 언어(slug) */
|
||||
language: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 줄 번호 표시 */
|
||||
showLineNumbers: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/** 본문 첫 줄 source-line(0-based) */
|
||||
bodySourceLine: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['commit', 'insert-above', 'insert-below', 'delete-line', 'focus-line'])
|
||||
|
||||
const languageDraft = ref(props.language)
|
||||
const lineNumbersEnabled = ref(props.showLineNumbers)
|
||||
const liveBody = ref(props.modelValue)
|
||||
|
||||
watch(() => props.language, (value) => {
|
||||
languageDraft.value = value
|
||||
})
|
||||
|
||||
watch(() => props.showLineNumbers, (value) => {
|
||||
lineNumbersEnabled.value = value
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
liveBody.value = value
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<string[]>} */
|
||||
const bodyLines = computed(() => {
|
||||
const text = String(liveBody.value ?? '')
|
||||
|
||||
if (!text.length) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
return text.split('\n')
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<number[]>} */
|
||||
const gutterLines = computed(() => bodyLines.value.map((_, index) => index + 1))
|
||||
|
||||
/**
|
||||
* 마크다운에 코드 블록을 반영한다.
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const commitCodeBlock = (body) => {
|
||||
emit('commit', buildCodeBlockLines({
|
||||
language: languageDraft.value,
|
||||
showLineNumbers: lineNumbersEnabled.value,
|
||||
body
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 편집 반영
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyCommit = (body) => {
|
||||
liveBody.value = body
|
||||
commitCodeBlock(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 중 줄 번호 갱신용 본문 동기화
|
||||
* @param {string} body - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBodyInput = (body) => {
|
||||
liveBody.value = body
|
||||
commitCodeBlock(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 블록 아래로 이탈(다음 문단 생성)
|
||||
* @param {Object} payload - insert-below 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onExitBelow = (payload) => {
|
||||
emit('insert-below', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 언어 입력 반영
|
||||
* @returns {void}
|
||||
*/
|
||||
const onLanguageCommit = () => {
|
||||
commitCodeBlock(props.modelValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄 번호 표시를 토글한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleLineNumbers = () => {
|
||||
lineNumbersEnabled.value = !lineNumbersEnabled.value
|
||||
commitCodeBlock(props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProseCodeBlock
|
||||
class="content-markdown-code-block-editor"
|
||||
:show-line-numbers="lineNumbersEnabled"
|
||||
:line-numbers="gutterLines"
|
||||
:data-source-line="bodySourceLine - 1"
|
||||
:data-source-line-end="bodySourceLine + bodyLines.length"
|
||||
>
|
||||
<template #header-tools>
|
||||
<div
|
||||
class="content-markdown-code-block-editor__toolbar pointer-events-none flex items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<button
|
||||
class="content-markdown-code-block-editor__line-numbers pointer-events-auto rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/15 hover:text-white"
|
||||
type="button"
|
||||
:aria-pressed="lineNumbersEnabled"
|
||||
:title="lineNumbersEnabled ? '줄 번호 숨기기' : '줄 번호 표시'"
|
||||
@mousedown.prevent
|
||||
@click="toggleLineNumbers"
|
||||
>
|
||||
{{ lineNumbersEnabled ? '줄번호' : '줄번호 표시 안함' }}
|
||||
</button>
|
||||
<input
|
||||
v-model="languageDraft"
|
||||
class="content-markdown-code-block-editor__language pointer-events-auto w-[7.5rem] rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs text-white outline-none transition-colors placeholder:text-white/35 focus:border-white/30 focus:bg-white/15"
|
||||
type="text"
|
||||
placeholder="Language..."
|
||||
spellcheck="false"
|
||||
@mousedown.stop
|
||||
@keydown.stop
|
||||
@blur="onLanguageCommit"
|
||||
@keydown.enter.prevent="onLanguageCommit"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ContentMarkdownEditableInline
|
||||
tag="pre"
|
||||
block-class="content-markdown-code-block-editor__editor m-0 min-w-0 border-0 bg-transparent p-0 font-mono text-sm leading-6 text-white outline-none"
|
||||
enter-mode="multiline"
|
||||
plain-text
|
||||
arrow-exit-creates-line
|
||||
:source-line="bodySourceLine"
|
||||
:source-line-count="bodyLines.length"
|
||||
:model-value="modelValue"
|
||||
@input="onBodyInput"
|
||||
@commit="onBodyCommit"
|
||||
@insert-above="emit('insert-above', $event)"
|
||||
@insert-below="onExitBelow"
|
||||
@delete-line="emit('delete-line', $event)"
|
||||
@focus-line="emit('focus-line', $event)"
|
||||
/>
|
||||
</ProseCodeBlock>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-markdown-code-block-editor :deep(.prose-code-block__content) {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.content-markdown-code-block-editor :deep(.prose-code-block__header) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-markdown-code-block-editor__toolbar {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-markdown-code-block-editor__toolbar > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
1639
components/content/ContentMarkdownEditableInline.vue
Normal file
1639
components/content/ContentMarkdownEditableInline.vue
Normal file
File diff suppressed because it is too large
Load Diff
163
components/content/ContentMarkdownInlineSegments.vue
Normal file
163
components/content/ContentMarkdownInlineSegments.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
import { parseInlineSegments } from '../../lib/markdown-inline.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 렌더링할 인라인 마크다운 텍스트 */
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 링크에 적용할 클래스 */
|
||||
linkClass: {
|
||||
type: String,
|
||||
default: 'text-[var(--site-accent)] underline underline-offset-4'
|
||||
},
|
||||
/** 줄바꿈을 br로 렌더링할지 여부 */
|
||||
multiline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const segmentLines = computed(() => {
|
||||
const source = String(props.text || '')
|
||||
const normalizedSource = props.multiline
|
||||
? source.replace(/<br\s*\/?>/gi, '\n')
|
||||
: source
|
||||
const lines = props.multiline ? normalizedSource.split('\n') : [normalizedSource]
|
||||
return lines.map(parseInlineSegments)
|
||||
})
|
||||
|
||||
/**
|
||||
* 인라인 색상 클래스명을 반환한다.
|
||||
* @param {string} color - 색상 이름
|
||||
* @returns {string} 클래스명
|
||||
*/
|
||||
const getInlineColorClass = (color) => `content-markdown-inline-segments__color--${String(color || '').trim().toLowerCase()}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="content-markdown-inline-segments">
|
||||
<span
|
||||
v-for="(segment, segmentIndex) in segmentLines[0] || []"
|
||||
:key="`inline-segment-0-${segmentIndex}`"
|
||||
class="content-markdown-inline-segments__segment"
|
||||
>
|
||||
<strong v-if="segment.type === 'strong'">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</strong>
|
||||
<em v-else-if="segment.type === 'em'">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-inline-segments__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" :class="linkClass" :href="segment.href" target="_blank" rel="noreferrer">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</a>
|
||||
<span
|
||||
v-else-if="segment.type === 'color'"
|
||||
class="content-markdown-inline-segments__color"
|
||||
:class="getInlineColorClass(segment.color)"
|
||||
:data-inline-color="segment.color"
|
||||
>
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</span>
|
||||
<span v-else>{{ segment.text }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-for="(lineSegments, offsetIndex) in segmentLines.slice(1)"
|
||||
:key="`inline-line-${offsetIndex + 1}`"
|
||||
class="content-markdown-inline-segments__line"
|
||||
>
|
||||
<br>
|
||||
<span
|
||||
v-for="(segment, segmentIndex) in lineSegments"
|
||||
:key="`inline-segment-${offsetIndex + 1}-${segmentIndex}`"
|
||||
class="content-markdown-inline-segments__segment"
|
||||
>
|
||||
<strong v-if="segment.type === 'strong'">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</strong>
|
||||
<em v-else-if="segment.type === 'em'">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-inline-segments__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" :class="linkClass" :href="segment.href" target="_blank" rel="noreferrer">
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</a>
|
||||
<span
|
||||
v-else-if="segment.type === 'color'"
|
||||
class="content-markdown-inline-segments__color"
|
||||
:class="getInlineColorClass(segment.color)"
|
||||
:data-inline-color="segment.color"
|
||||
>
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
|
||||
</span>
|
||||
<span v-else>{{ segment.text }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-markdown-inline-segments,
|
||||
.content-markdown-inline-segments__line,
|
||||
.content-markdown-inline-segments__segment {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--gray {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--blue {
|
||||
color: #0055ff;
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--green {
|
||||
color: #16ae68;
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--yellow {
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--red {
|
||||
color: #e60000;
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--purple {
|
||||
color: #8800ff;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--blue {
|
||||
color: #7aa2ff;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--green {
|
||||
color: #5ee0a0;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--yellow {
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--red {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--purple {
|
||||
color: #c084fc;
|
||||
}
|
||||
</style>
|
||||
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="defaultOpen"
|
||||
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"
|
||||
block-markdown
|
||||
arrow-exit-creates-line
|
||||
:source-line="bodySourceLine"
|
||||
:source-line-count="String(modelValue ?? '').split('\n').length"
|
||||
:model-value="modelValue"
|
||||
@commit="onBodyCommit"
|
||||
@insert-above="emit('insert-above', $event)"
|
||||
@insert-below="onExitBelow"
|
||||
@delete-line="emit('delete-line', $event)"
|
||||
/>
|
||||
</ProseToggle>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-markdown-toggle-editor__title:empty::before {
|
||||
content: '토글 제목';
|
||||
color: var(--site-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-markdown-toggle-editor__body:empty::before {
|
||||
content: '펼쳤을 때 보일 내용을 입력하세요';
|
||||
color: var(--site-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,60 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 표시용 오디오 제목을 반환한다.
|
||||
* @returns {string} 오디오 제목
|
||||
*/
|
||||
const displayTitle = computed(() => props.title || 'Audio')
|
||||
|
||||
/**
|
||||
* 재생 가능한 오디오 URL인지 확인한다.
|
||||
* @returns {boolean} 오디오 URL 여부
|
||||
*/
|
||||
const hasAudioSource = computed(() => Boolean(props.src && (props.src.startsWith('/') || /^https?:\/\//i.test(props.src))))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<slot />
|
||||
</div>
|
||||
<section class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-4 shadow-[0_14px_36px_rgba(15,23,42,0.06)] sm:p-5">
|
||||
<div class="prose-audio__inner flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div class="prose-audio__icon flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[var(--site-accent)] text-white sm:h-[86px] sm:w-[86px]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-9 w-9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 18V5l10-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="16" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="prose-audio__body min-w-0 flex-1">
|
||||
<p class="prose-audio__title mb-2 text-base font-semibold leading-snug text-[var(--site-text)] sm:text-lg">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p v-if="description" class="prose-audio__description mb-3 text-sm leading-relaxed text-[var(--site-muted)]">
|
||||
{{ description }}
|
||||
</p>
|
||||
<audio
|
||||
v-if="hasAudioSource"
|
||||
class="prose-audio__player w-full accent-[var(--site-accent)]"
|
||||
:src="src"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<p v-else class="prose-audio__empty text-sm font-semibold text-[var(--site-muted)]">
|
||||
오디오 URL이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,79 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
default: 'gray'
|
||||
}
|
||||
})
|
||||
|
||||
const backgroundClass = computed(() => {
|
||||
if (props.background === 'gray') {
|
||||
return 'prose-blockquote--gray'
|
||||
}
|
||||
|
||||
if (props.background === 'blue') {
|
||||
return 'prose-blockquote--blue'
|
||||
}
|
||||
|
||||
if (props.background === 'green') {
|
||||
return 'prose-blockquote--green'
|
||||
}
|
||||
|
||||
if (props.background === 'yellow') {
|
||||
return 'prose-blockquote--yellow'
|
||||
}
|
||||
|
||||
if (props.background === 'red') {
|
||||
return 'prose-blockquote--red'
|
||||
}
|
||||
|
||||
if (props.background === 'purple') {
|
||||
return 'prose-blockquote--purple'
|
||||
}
|
||||
|
||||
return 'prose-blockquote--gray'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<blockquote
|
||||
class="prose-blockquote my-8 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
class="prose-blockquote mb-5 text-[15px] leading-8"
|
||||
:class="variant === 'alt'
|
||||
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic'
|
||||
: 'rounded-[10px] border-l-2 border-[var(--site-text)] bg-[var(--site-panel)] px-5 py-4 font-medium'"
|
||||
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic text-[var(--site-text)]'
|
||||
: ['border-l-[3px] bg-transparent py-1 pl-5 pr-0 font-normal text-[var(--site-text)]', backgroundClass]"
|
||||
>
|
||||
<span class="whitespace-pre-line">
|
||||
<span class="block whitespace-pre-line">
|
||||
<slot />
|
||||
</span>
|
||||
</blockquote>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prose-blockquote--gray {
|
||||
border-color: #050505;
|
||||
}
|
||||
|
||||
.prose-blockquote--blue {
|
||||
border-color: #0055ff;
|
||||
}
|
||||
|
||||
.prose-blockquote--green {
|
||||
border-color: #16ae68;
|
||||
}
|
||||
|
||||
.prose-blockquote--yellow {
|
||||
border-color: #ffff00;
|
||||
}
|
||||
|
||||
.prose-blockquote--red {
|
||||
border-color: #ff0000;
|
||||
}
|
||||
|
||||
.prose-blockquote--purple {
|
||||
border-color: #8800ff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
|
||||
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
@@ -34,13 +36,7 @@ const displayHost = computed(() => {
|
||||
* 썸네일이 비었을 때 파비콘 보조 URL을 만든다.
|
||||
* @returns {string} favicon 요청 URL
|
||||
*/
|
||||
const faviconUrl = computed(() => {
|
||||
if (!displayHost.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(displayHost.value)}&sz=128`
|
||||
})
|
||||
const faviconUrl = computed(() => getExternalFaviconUrl(props.url, 128))
|
||||
|
||||
/**
|
||||
* 실제로 표시할 이미지 주소
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
href: {
|
||||
type: String,
|
||||
default: '#'
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
default: 'left'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="prose-button my-8" :class="{ 'text-center': align === 'center' }">
|
||||
<NuxtLink
|
||||
class="prose-button__link inline-flex items-center justify-center rounded-full bg-[var(--site-text)] px-5 py-2.5 text-sm font-semibold text-[var(--site-bg)] transition-opacity hover:opacity-80"
|
||||
:to="href"
|
||||
>
|
||||
<slot />
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { parseIframeEmbedMarkup } from '../../lib/markdown-iframe-embed.js'
|
||||
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
@@ -59,9 +61,49 @@ 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 customIframe = computed(() => parseIframeEmbedMarkup(props.url))
|
||||
const mastodonIframeRef = ref(null)
|
||||
const mastodonEmbedHeight = ref(640)
|
||||
const mastodonEmbedId = ref(0)
|
||||
|
||||
/**
|
||||
* 외부 링크로 열어도 되는 URL인지 확인한다.
|
||||
@@ -92,10 +134,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 mb-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 +214,36 @@ const tweetEmbedUrl = computed(() => {
|
||||
<iframe
|
||||
v-else-if="tweetEmbedUrl"
|
||||
:key="tweetEmbedUrl"
|
||||
class="prose-embed__tweet min-h-[420px] w-full border-0 sm:min-h-[458px]"
|
||||
class="prose-embed__tweet block min-h-[560px] w-full border-0 sm:min-h-[620px]"
|
||||
:src="tweetEmbedUrl"
|
||||
title="Embedded post"
|
||||
loading="lazy"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="mastodonEmbedUrl"
|
||||
:key="mastodonEmbedUrl"
|
||||
ref="mastodonIframeRef"
|
||||
class="prose-embed__mastodon block w-full border-0"
|
||||
:src="mastodonEmbedUrl"
|
||||
:height="mastodonEmbedHeight"
|
||||
title="Embedded Mastodon post"
|
||||
allow="fullscreen"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
scrolling="no"
|
||||
@load="requestMastodonEmbedHeight"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="customIframe"
|
||||
class="prose-embed__custom block w-full border-0"
|
||||
:src="customIframe.src"
|
||||
:title="customIframe.title"
|
||||
:allow="customIframe.allow"
|
||||
:height="customIframe.height || 480"
|
||||
:width="customIframe.width || undefined"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-presentation"
|
||||
/>
|
||||
<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"
|
||||
class="prose-heading mt-12 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||
:id="id || undefined"
|
||||
class="prose-heading mb-2.5 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||
style="scroll-margin-top: calc(var(--site-top-chrome-height, 57px) + 24px)"
|
||||
:class="{
|
||||
'text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]': level === 1,
|
||||
'text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]': level === 2,
|
||||
|
||||
249
components/content/ProseHtml.vue
Normal file
249
components/content/ProseHtml.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
html: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const allowedHtmlBlockTags = new Set([
|
||||
'a',
|
||||
'abbr',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'caption',
|
||||
'code',
|
||||
'col',
|
||||
'colgroup',
|
||||
'dd',
|
||||
'details',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'em',
|
||||
'figcaption',
|
||||
'figure',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'i',
|
||||
'img',
|
||||
'li',
|
||||
'mark',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
's',
|
||||
'small',
|
||||
'span',
|
||||
'strong',
|
||||
'sub',
|
||||
'summary',
|
||||
'sup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'time',
|
||||
'tr',
|
||||
'u',
|
||||
'ul'
|
||||
])
|
||||
const globalAllowedAttributes = new Set(['class', 'title', 'aria-label', 'aria-hidden'])
|
||||
const allowedAttributesByTag = {
|
||||
a: new Set(['href', 'target', 'rel']),
|
||||
col: new Set(['span', 'width']),
|
||||
img: new Set(['src', 'alt', 'width', 'height', 'loading']),
|
||||
li: new Set(['value']),
|
||||
ol: new Set(['start', 'type']),
|
||||
td: new Set(['colspan', 'rowspan', 'headers']),
|
||||
th: new Set(['colspan', 'rowspan', 'headers', 'scope']),
|
||||
time: new Set(['datetime'])
|
||||
}
|
||||
const urlAttributeNames = new Set(['href', 'src'])
|
||||
const allowedUrlSchemes = new Set(['http:', 'https:', 'mailto:', 'tel:'])
|
||||
|
||||
/**
|
||||
* HTML 텍스트를 escape한다.
|
||||
* @param {unknown} value - 원본 값
|
||||
* @returns {string} HTML escape 문자열
|
||||
*/
|
||||
const escapeHtmlText = (value) => String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
/**
|
||||
* 브라우저에서 URL 속성 값을 허용할지 확인한다.
|
||||
* @param {string} value - URL 값
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
const isAllowedBrowserUrl = (value) => {
|
||||
const trimmedValue = String(value || '').trim()
|
||||
|
||||
if (!trimmedValue || trimmedValue.startsWith('//')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (trimmedValue.startsWith('/') || trimmedValue.startsWith('#')) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
return allowedUrlSchemes.has(new URL(trimmedValue, window.location.origin).protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저에서 HTML 노드를 허용 목록 기준으로 정리한다.
|
||||
* @param {Node} node - 정리할 노드
|
||||
* @returns {Node|null} 정리된 노드
|
||||
*/
|
||||
const sanitizeBrowserNode = (node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return document.createTextNode(node.textContent || '')
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return null
|
||||
}
|
||||
|
||||
const element = /** @type {Element} */ (node)
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
|
||||
if (!allowedHtmlBlockTags.has(tagName)) {
|
||||
const fragment = document.createDocumentFragment()
|
||||
Array.from(element.childNodes).forEach((childNode) => {
|
||||
const sanitizedChild = sanitizeBrowserNode(childNode)
|
||||
if (sanitizedChild) {
|
||||
fragment.appendChild(sanitizedChild)
|
||||
}
|
||||
})
|
||||
return fragment
|
||||
}
|
||||
|
||||
const sanitizedElement = document.createElement(tagName)
|
||||
const tagAllowedAttributes = allowedAttributesByTag[tagName] || new Set()
|
||||
|
||||
Array.from(element.attributes).forEach((attribute) => {
|
||||
const attributeName = attribute.name.toLowerCase()
|
||||
|
||||
if (!globalAllowedAttributes.has(attributeName) && !tagAllowedAttributes.has(attributeName)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (urlAttributeNames.has(attributeName) && !isAllowedBrowserUrl(attribute.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
sanitizedElement.setAttribute(attributeName, attribute.value)
|
||||
})
|
||||
|
||||
if (tagName === 'a' && sanitizedElement.getAttribute('target') === '_blank') {
|
||||
const relValues = new Set(String(sanitizedElement.getAttribute('rel') || '').split(/\s+/).filter(Boolean))
|
||||
relValues.add('noopener')
|
||||
relValues.add('noreferrer')
|
||||
sanitizedElement.setAttribute('rel', [...relValues].join(' '))
|
||||
}
|
||||
|
||||
Array.from(element.childNodes).forEach((childNode) => {
|
||||
const sanitizedChild = sanitizeBrowserNode(childNode)
|
||||
if (sanitizedChild) {
|
||||
sanitizedElement.appendChild(sanitizedChild)
|
||||
}
|
||||
})
|
||||
|
||||
return sanitizedElement
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저에서 HTML 블록을 정리한다.
|
||||
* @param {unknown} value - 원본 HTML
|
||||
* @returns {string} 정리된 HTML
|
||||
*/
|
||||
const sanitizeHtmlBlockInBrowser = (value) => {
|
||||
const html = String(value || '').trim()
|
||||
|
||||
if (!html) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!import.meta.client || typeof DOMParser === 'undefined') {
|
||||
return escapeHtmlText(html)
|
||||
}
|
||||
|
||||
const parser = new DOMParser()
|
||||
const documentFragment = document.createDocumentFragment()
|
||||
const parsedDocument = parser.parseFromString(`<template>${html}</template>`, 'text/html')
|
||||
const template = parsedDocument.querySelector('template')
|
||||
|
||||
Array.from(template?.content.childNodes || []).forEach((node) => {
|
||||
const sanitizedNode = sanitizeBrowserNode(node)
|
||||
if (sanitizedNode) {
|
||||
documentFragment.appendChild(sanitizedNode)
|
||||
}
|
||||
})
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.appendChild(documentFragment)
|
||||
return container.innerHTML.trim()
|
||||
}
|
||||
|
||||
const sanitizeHtmlBlock = import.meta.server
|
||||
? (await import('../../lib/markdown-html-block.js')).sanitizeHtmlBlock
|
||||
: sanitizeHtmlBlockInBrowser
|
||||
|
||||
const sanitizedHtml = computed(() => sanitizeHtmlBlock(props.html))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="sanitizedHtml"
|
||||
class="prose-html prose-html-block mb-8 overflow-x-auto text-base leading-7 text-[var(--site-text)]"
|
||||
v-html="sanitizedHtml"
|
||||
/>
|
||||
<p v-else class="prose-html prose-html-block mb-8 text-sm font-semibold text-[var(--site-muted)]">
|
||||
표시할 수 있는 HTML이 없습니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prose-html :deep(table) {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.prose-html :deep(th),
|
||||
.prose-html :deep(td) {
|
||||
border: 1px solid var(--site-line);
|
||||
padding: 0.5rem 0.75rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.prose-html :deep(th) {
|
||||
background: color-mix(in srgb, var(--site-text) 6%, transparent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prose-html :deep(p) {
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.prose-html :deep(a) {
|
||||
color: var(--site-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +1,190 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { getImageDefaultAltLabel } from '../../lib/markdown-image.js'
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
default: ''
|
||||
},
|
||||
fallbackSrc: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
srcset: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
sizes: {
|
||||
type: String,
|
||||
default: '(max-width: 768px) 100vw, 760px'
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 이미지 아래 표시용 캡션 */
|
||||
caption: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'regular'
|
||||
},
|
||||
pixelWidth: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
expandable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['activate'])
|
||||
|
||||
const loadFailed = ref(false)
|
||||
const currentSrc = ref(props.src)
|
||||
|
||||
const hasRenderableSrc = computed(() => String(currentSrc.value || '').trim().length > 0)
|
||||
const currentSrcset = computed(() => currentSrc.value === props.src ? props.srcset : '')
|
||||
const figureStyle = computed(() => {
|
||||
const width = Math.round(Number(props.pixelWidth) || 0)
|
||||
|
||||
if (!width) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
maxWidth: `${width}px`,
|
||||
width: '100%'
|
||||
}
|
||||
})
|
||||
|
||||
const errorLabel = computed(() => {
|
||||
const trimmed = String(props.fallbackSrc || props.src || '').trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return '이미지 URL이 비어 있습니다'
|
||||
}
|
||||
|
||||
const filename = getImageDefaultAltLabel(trimmed)
|
||||
|
||||
return filename ? `이미지를 불러올 수 없습니다 · ${filename}` : '이미지를 불러올 수 없습니다'
|
||||
})
|
||||
|
||||
watch(() => props.src, () => {
|
||||
currentSrc.value = props.src
|
||||
loadFailed.value = false
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 로드 실패 시 placeholder를 표시한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageError = () => {
|
||||
if (props.fallbackSrc && currentSrc.value !== props.fallbackSrc) {
|
||||
currentSrc.value = props.fallbackSrc
|
||||
return
|
||||
}
|
||||
|
||||
loadFailed.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 로드 성공 시 오류 상태를 해제한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onImageLoad = () => {
|
||||
loadFailed.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 확대 요청을 전달한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const activateImage = () => {
|
||||
if (!props.expandable || !hasRenderableSrc.value || loadFailed.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('activate')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure
|
||||
class="prose-image my-8"
|
||||
class="prose-image mb-2.5"
|
||||
:style="figureStyle"
|
||||
:class="{
|
||||
'prose-image--wide lg:-mx-10 lg:max-w-none': variant === 'wide',
|
||||
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
|
||||
}"
|
||||
>
|
||||
<div class="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
|
||||
</div>
|
||||
<figcaption v-if="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-[var(--site-muted)]">
|
||||
<slot />
|
||||
<component
|
||||
:is="expandable && hasRenderableSrc && !loadFailed ? 'button' : 'div'"
|
||||
class="prose-image__frame block w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-0 text-left text-inherit"
|
||||
:class="{
|
||||
'prose-image__frame--empty': !hasRenderableSrc || loadFailed,
|
||||
'prose-image__frame--broken': loadFailed,
|
||||
'prose-image__frame--expandable cursor-zoom-in': expandable && hasRenderableSrc && !loadFailed
|
||||
}"
|
||||
:type="expandable && hasRenderableSrc && !loadFailed ? 'button' : undefined"
|
||||
:aria-label="expandable && hasRenderableSrc && !loadFailed ? '이미지 크게 보기' : undefined"
|
||||
@click="activateImage"
|
||||
>
|
||||
<img
|
||||
v-if="hasRenderableSrc && !loadFailed"
|
||||
class="prose-image__media w-full object-cover"
|
||||
:src="currentSrc"
|
||||
:srcset="currentSrcset || undefined"
|
||||
:sizes="currentSrcset ? sizes : undefined"
|
||||
:alt="alt"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@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>
|
||||
</component>
|
||||
<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,5 +0,0 @@
|
||||
<template>
|
||||
<div class="prose-product my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,19 +1,115 @@
|
||||
<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 cursor-pointer text-[15px] font-semibold leading-7 text-[var(--site-text)]"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<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>
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup>
|
||||
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
|
||||
|
||||
defineProps({
|
||||
menuOpen: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
menuMounted: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,12 +21,101 @@ const { data: tags } = await useFetch('/api/tags', {
|
||||
const { data: navigation } = await useFetch('/api/navigation', {
|
||||
default: () => ({
|
||||
primary: [],
|
||||
footer: []
|
||||
footer: [],
|
||||
recommended: []
|
||||
})
|
||||
})
|
||||
|
||||
/** 저자 영역 공개 여부 */
|
||||
const showAuthorSection = false
|
||||
|
||||
const STORAGE_KEY = 'sori-primary-nav-expanded'
|
||||
|
||||
/**
|
||||
* 공개 추천 사이트 목록을 반환한다.
|
||||
* @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((item) => item?.isVisible !== false)
|
||||
})
|
||||
|
||||
/**
|
||||
* 새 탭으로 열 외부 URL인지 확인한다.
|
||||
* @param {string} url - 링크
|
||||
* @returns {boolean} 외부 URL 여부
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
||||
/** 추천 사이트 이미지 로드 실패 단계 (`thumbnail` → `favicon` → `letter`) */
|
||||
const recommendedImageStage = ref({})
|
||||
|
||||
/**
|
||||
* 추천 사이트 파비콘 URL을 반환한다.
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @returns {string} 파비콘 URL
|
||||
*/
|
||||
const getRecommendedFaviconUrl = (item) => {
|
||||
const cachedFaviconUrl = String(item?.cachedFaviconUrl || '').trim()
|
||||
if (cachedFaviconUrl) {
|
||||
return cachedFaviconUrl
|
||||
}
|
||||
return getExternalFaviconUrl(item?.url, 64)
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 사이트 이미지 URL을 반환한다.
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @returns {string} 이미지 URL
|
||||
*/
|
||||
const getRecommendedImageUrl = (item) => {
|
||||
const id = String(item?.id || '')
|
||||
const stage = recommendedImageStage.value[id] || 'thumbnail'
|
||||
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
||||
const faviconUrl = getRecommendedFaviconUrl(item)
|
||||
|
||||
if (stage === 'letter') {
|
||||
return ''
|
||||
}
|
||||
if (stage === 'favicon') {
|
||||
return faviconUrl
|
||||
}
|
||||
return thumbnailUrl || faviconUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 사이트 이미지 로드 실패 시 다음 단계로 넘긴다.
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const onRecommendedImageError = (item) => {
|
||||
const id = String(item?.id || '')
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
||||
const faviconUrl = getRecommendedFaviconUrl(item)
|
||||
const stage = recommendedImageStage.value[id] || 'thumbnail'
|
||||
|
||||
if (stage === 'thumbnail' && thumbnailUrl && faviconUrl) {
|
||||
recommendedImageStage.value = { ...recommendedImageStage.value, [id]: 'favicon' }
|
||||
return
|
||||
}
|
||||
recommendedImageStage.value = { ...recommendedImageStage.value, [id]: 'letter' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리에서 하위가 있는 노드 id를 모은다.
|
||||
* @param {Array<Object>} list - 노드 목록
|
||||
@@ -137,10 +232,16 @@ onMounted(() => {
|
||||
<template>
|
||||
<aside
|
||||
id="menu"
|
||||
class="left-sidebar site-sidebar flex flex-col overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,transform,border-color] duration-300 ease-out max-lg:fixed max-lg:left-0 max-lg:top-[57px] max-lg:z-[60] max-lg:h-[calc(100dvh-57px)] max-lg:max-h-[calc(100dvh-57px)] max-lg:w-[min(287px,calc(100vw-24px))] max-lg:shadow-[0_16px_48px_rgba(0,0,0,0.18)] lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:self-start"
|
||||
:class="menuOpen
|
||||
? 'max-lg:translate-x-0 max-lg:pointer-events-auto lg:w-[287px] lg:opacity-100'
|
||||
: 'max-lg:-translate-x-full max-lg:pointer-events-none lg:w-0 lg:opacity-0 lg:border-transparent'"
|
||||
class="left-sidebar site-sidebar flex flex-col overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,transform,border-color] duration-300 ease-out max-lg:fixed max-lg:left-0 max-lg:z-[60] max-lg:w-[min(287px,calc(100vw-24px))] max-lg:shadow-[0_16px_48px_rgba(0,0,0,0.18)] lg:sticky lg:z-10 lg:self-start"
|
||||
style="top: var(--site-top-chrome-height, 57px); height: calc(100dvh - var(--site-top-chrome-height, 57px)); max-height: calc(100dvh - var(--site-top-chrome-height, 57px))"
|
||||
:class="[
|
||||
menuOpen
|
||||
? 'lg:w-[287px] lg:opacity-100'
|
||||
: 'lg:w-0 lg:opacity-0 lg:border-transparent',
|
||||
menuOpen && menuMounted
|
||||
? 'max-lg:translate-x-0 max-lg:pointer-events-auto'
|
||||
: 'max-lg:-translate-x-full max-lg:pointer-events-none'
|
||||
]"
|
||||
>
|
||||
<div class="left-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||
<div class="left-sidebar__block site-sidebar-section py-3 pl-4 pr-3 sm:pl-5 xl:pl-0">
|
||||
@@ -158,7 +259,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 +274,47 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
|
||||
<div
|
||||
v-if="recommendedSites.length"
|
||||
class="left-sidebar__block site-sidebar-section px-5 py-4 pr-3 xl:pl-0"
|
||||
>
|
||||
<div class="left-sidebar__section-title flex items-center justify-between pr-2 text-xs font-semibold uppercase tracking-[0.01em] site-muted">
|
||||
<span>Recommended</span>
|
||||
<span class="text-sm">⌃</span>
|
||||
</div>
|
||||
<ul class="left-sidebar__recommended-list mt-3 flex list-none flex-col gap-2 p-0">
|
||||
<li v-for="item in recommendedSites" :key="item.id">
|
||||
<a
|
||||
class="left-sidebar__recommended-card site-interactive flex items-center gap-2.5 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] px-2.5 py-2 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="left-sidebar__recommended-icon grid h-8 w-8 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="32"
|
||||
height="32"
|
||||
:alt="item.thumbnailUrl ? item.label : ''"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onRecommendedImageError(item)"
|
||||
>
|
||||
<span v-else class="px-1 text-center leading-none">{{ (item.label || '?').slice(0, 1) }}</span>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-semibold text-[var(--site-text)]">{{ item.label }}</span>
|
||||
<span class="mt-0.5 block truncate text-[11px] site-muted" :class="item.descriptionText ? '' : 'font-mono'">{{ getRecommendedDisplayText(item) }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 text-xs site-muted" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="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>
|
||||
@@ -206,7 +347,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,29 +8,67 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="post-card site-section site-panel-hover">
|
||||
<article class="post-card site-section group relative">
|
||||
<div class="post-card__body site-section-body flex gap-4">
|
||||
<img
|
||||
v-if="post.featuredImage"
|
||||
class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-surface object-cover"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
loading="lazy"
|
||||
>
|
||||
<div v-else class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-[linear-gradient(135deg,#06333a,#f4a261)]" />
|
||||
<PostCardMedia
|
||||
:to="post.to"
|
||||
:title="post.title"
|
||||
:featured-image="post.featuredImage"
|
||||
:thumbnail-image="post.featuredImageThumbnail"
|
||||
link-class="h-20 w-36 shrink-0"
|
||||
aspect-class="h-full w-full"
|
||||
/>
|
||||
<div class="post-card__content min-w-0">
|
||||
<h2 class="post-card__title text-base font-semibold leading-tight">
|
||||
<NuxtLink class="post-card__title-link site-interactive hover:opacity-70" :to="post.to">
|
||||
<NuxtLink class="post-card__title-link post-title-link" :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 }}
|
||||
<p class="post-card__meta mt-2 flex flex-wrap items-center gap-1.5 text-xs site-muted">
|
||||
{{ post.publishedAt }}<template v-if="post.tag"> / </template>
|
||||
<NuxtLink
|
||||
v-if="post.tag && post.tagSlug"
|
||||
class="post-card__tag-link site-interactive rounded-md px-1.5 py-px font-medium text-[var(--site-text)] hover:text-[var(--site-accent)]"
|
||||
:style="post.tagColor ? { backgroundColor: `${post.tagColor}1a` } : undefined"
|
||||
:to="`/tag/${post.tagSlug}`"
|
||||
>
|
||||
{{ post.tag }}
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-else-if="post.tag"
|
||||
class="post-card__tag-label rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||
:style="post.tagColor ? { backgroundColor: `${post.tagColor}1a` } : undefined"
|
||||
>
|
||||
{{ post.tag }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
class="post-card__arrow absolute right-6 bottom-4 flex cursor-pointer items-center gap-1 text-[var(--site-text)] opacity-100 transition-[opacity,visibility] duration-200 hover:opacity-75 md:invisible md:opacity-0 md:group-hover:visible md:group-hover:opacity-100"
|
||||
:to="post.to"
|
||||
aria-label="게시물 보기"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
71
components/site/PostCardMedia.vue
Normal file
71
components/site/PostCardMedia.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/** 게시물 링크 */
|
||||
to: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 게시물 제목 */
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 대표 이미지 URL */
|
||||
featuredImage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 목록 표시용 대표 이미지 썸네일 URL */
|
||||
thumbnailImage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 썸네일 비율·크기 Tailwind 클래스 */
|
||||
aspectClass: {
|
||||
type: String,
|
||||
default: 'aspect-square sm:aspect-video'
|
||||
},
|
||||
/** 링크 래퍼 추가 클래스 */
|
||||
linkClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 이미지 추가 클래스 */
|
||||
imageClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const displayImage = computed(() => props.thumbnailImage || props.featuredImage)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
class="post-card-media relative block"
|
||||
:class="linkClass"
|
||||
data-post-card-media
|
||||
>
|
||||
<figure
|
||||
class="post-card-media__figure overflow-hidden rounded-[10px]"
|
||||
:class="aspectClass"
|
||||
>
|
||||
<img
|
||||
v-if="displayImage"
|
||||
class="post-card-media__image h-full w-full rounded-[inherit] object-cover"
|
||||
:class="imageClass"
|
||||
:src="displayImage"
|
||||
:alt="title"
|
||||
loading="lazy"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="post-card-media__placeholder flex h-full w-full items-center justify-center rounded-[inherit] bg-[#F7F4EF] p-4 text-center text-xs leading-snug text-[var(--site-muted)]"
|
||||
:aria-label="title"
|
||||
>
|
||||
<span class="post-card-media__placeholder-text max-w-full break-words line-clamp-3 sm:line-clamp-4">{{ title }}</span>
|
||||
</span>
|
||||
</figure>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -1,30 +1,196 @@
|
||||
<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 { getVisibleSocialLinks } from '~/lib/social-links.js'
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'sori.studio',
|
||||
description: 'sori.studio 개인 블로그',
|
||||
logoText: '井',
|
||||
logoUrl: '',
|
||||
copyrightText: '©2026 sori.studio'
|
||||
const route = useRoute()
|
||||
const postToc = useState('post-detail-toc', () => [])
|
||||
const tocNavRef = ref(null)
|
||||
const activeTocId = ref('')
|
||||
let tocScrollFrame = 0
|
||||
|
||||
const { data: siteSettings } = await usePublicSiteSettings()
|
||||
|
||||
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||
const sidebarAdCode = computed(() => isPostDetailRoute.value ? siteSettings.value?.adPostSidebarCode : siteSettings.value?.adSidebarCode)
|
||||
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
|
||||
const followLinks = computed(() => getVisibleSocialLinks(siteSettings.value?.socialLinks || []))
|
||||
|
||||
/**
|
||||
* 고정 상단 영역을 고려한 TOC 판정 기준선을 반환한다.
|
||||
* @returns {number} 뷰포트 상단 기준 오프셋
|
||||
*/
|
||||
const getTocActivationOffset = () => {
|
||||
if (!import.meta.client) {
|
||||
return 96
|
||||
}
|
||||
|
||||
const topChromeHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--site-top-chrome-height'))
|
||||
|
||||
return (Number.isFinite(topChromeHeight) ? topChromeHeight : 57) + 28
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 본문 스크롤 위치에 해당하는 TOC 항목 ID를 계산한다.
|
||||
* @returns {string} 활성 제목 ID
|
||||
*/
|
||||
const findActiveTocId = () => {
|
||||
if (!import.meta.client || !postTocItems.value.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const offset = getTocActivationOffset()
|
||||
const currentY = window.scrollY + offset
|
||||
let activeId = postTocItems.value[0].id
|
||||
|
||||
for (const item of postTocItems.value) {
|
||||
const target = document.getElementById(item.id)
|
||||
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
|
||||
const targetY = target.getBoundingClientRect().top + window.scrollY
|
||||
|
||||
if (targetY <= currentY) {
|
||||
activeId = item.id
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return activeId
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 TOC 링크가 내부 스크롤 영역 안에 보이도록 보정한다.
|
||||
* @param {string} id - 활성 제목 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const scrollActiveTocIntoView = (id) => {
|
||||
if (!import.meta.client || !id || !(tocNavRef.value instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const nav = tocNavRef.value
|
||||
const scrollContainer = nav.closest('.site-sidebar-scroll')
|
||||
const link = nav.querySelector(`[data-toc-id="${id}"]`)
|
||||
|
||||
if (!(link instanceof HTMLElement) || !(scrollContainer instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const linkRect = link.getBoundingClientRect()
|
||||
const navTop = scrollContainer.scrollTop
|
||||
const navBottom = navTop + scrollContainer.clientHeight
|
||||
const linkTop = navTop + linkRect.top - containerRect.top
|
||||
const linkBottom = linkTop + link.offsetHeight
|
||||
const buffer = 24
|
||||
|
||||
if (linkTop < navTop + buffer) {
|
||||
scrollContainer.scrollTo({
|
||||
top: Math.max(0, linkTop - buffer),
|
||||
behavior: 'smooth'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (linkBottom > navBottom - buffer) {
|
||||
scrollContainer.scrollTo({
|
||||
top: linkBottom - scrollContainer.clientHeight + buffer,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크롤 이벤트에서 TOC 활성 항목을 갱신한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveToc = () => {
|
||||
if (!import.meta.client || tocScrollFrame) {
|
||||
return
|
||||
}
|
||||
|
||||
tocScrollFrame = window.requestAnimationFrame(() => {
|
||||
tocScrollFrame = 0
|
||||
const nextActiveId = findActiveTocId()
|
||||
|
||||
if (!nextActiveId || nextActiveId === activeTocId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
activeTocId.value = nextActiveId
|
||||
scrollActiveTocIntoView(nextActiveId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 목차 링크를 부드럽게 이동한다.
|
||||
* @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">
|
||||
<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:z-10 lg:w-[287px] lg:self-start lg:border-l-0 lg:border-t-0 lg:px-0">
|
||||
<div class="right-sidebar__scroll site-sidebar-scroll flex min-h-0 flex-1 flex-col">
|
||||
<div v-if="!isPostDetailRoute" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-5 sm:pr-0 max-lg:px-0">
|
||||
<div class="right-sidebar__profile flex items-center gap-3">
|
||||
<div class="right-sidebar__logo grid h-12 w-12 place-items-center 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 shrink-0 place-items-center overflow-hidden rounded-2xl text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
<img
|
||||
v-if="siteSettings.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
@@ -42,15 +208,9 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="right-sidebar__subscribe mt-4 flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<input class="right-sidebar__input min-w-0 w-full flex-1 rounded-lg px-3 py-2 text-sm site-input sm:min-w-0" placeholder="Your email">
|
||||
<button class="right-sidebar__button shrink-0 rounded-lg px-4 py-2 text-sm font-semibold site-button sm:self-auto" type="button">
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<div v-if="!isPostDetailRoute && followLinks.length" class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0">
|
||||
<div class="right-sidebar__row flex items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
Follow
|
||||
@@ -59,11 +219,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 +234,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 +247,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 +261,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 +274,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 +289,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 +339,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 +350,49 @@ 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 class="right-sidebar__row flex items-center justify-between">
|
||||
<div
|
||||
v-if="isPostDetailRoute"
|
||||
class="right-sidebar__block right-sidebar__toc py-5 pl-5 pr-0 max-lg:hidden"
|
||||
>
|
||||
<div class="right-sidebar__row flex shrink-0 items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
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>
|
||||
<nav ref="tocNavRef" class="right-sidebar__toc-nav mt-4 pr-2" aria-label="게시글 목차">
|
||||
<ul v-if="postTocItems.length" class="right-sidebar__toc-list flex list-none flex-col gap-2 border-l border-[var(--site-line)] p-0">
|
||||
<li
|
||||
v-for="item in postTocItems"
|
||||
:key="item.id"
|
||||
class="right-sidebar__toc-item relative flex min-h-6 items-center transition-colors"
|
||||
:class="activeTocId === item.id ? 'right-sidebar__toc-item--active' : ''"
|
||||
>
|
||||
<a
|
||||
class="right-sidebar__toc-link site-interactive flex min-h-6 w-full items-center rounded-md pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
|
||||
:class="{
|
||||
'bg-[var(--site-panel)] text-[var(--site-accent)] font-semibold': activeTocId === item.id,
|
||||
'text-[var(--site-text)]': activeTocId !== item.id,
|
||||
'pl-4 font-semibold': item.level === 1,
|
||||
'pl-7': item.level === 2,
|
||||
'pl-10': item.level === 3,
|
||||
'site-muted': item.level === 3 && activeTocId !== item.id
|
||||
}"
|
||||
:href="`#${item.id}`"
|
||||
:aria-current="activeTocId === item.id ? 'location' : undefined"
|
||||
:data-toc-id="item.id"
|
||||
@click="scrollToTocItem($event, item.id)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="right-sidebar__toc-empty text-sm site-muted">
|
||||
목차로 표시할 제목이 없습니다.
|
||||
</p>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div 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 +400,12 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
About {{ siteSettings.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<SiteAdSlot
|
||||
class="right-sidebar__ad-slot py-5 pl-5 pr-0 max-lg:px-0"
|
||||
:code="sidebarAdCode"
|
||||
:location="isPostDetailRoute ? 'post-sidebar-right' : 'sidebar'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-3 text-xs site-muted max-lg:px-0">
|
||||
@@ -191,3 +413,34 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 1024px) {
|
||||
.right-sidebar {
|
||||
top: var(--site-top-chrome-height, 57px);
|
||||
height: calc(100dvh - var(--site-top-chrome-height, 57px));
|
||||
max-height: calc(100dvh - var(--site-top-chrome-height, 57px));
|
||||
}
|
||||
}
|
||||
|
||||
.right-sidebar__custom-social-icon :deep(svg) {
|
||||
display: block;
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
max-width: 1rem;
|
||||
max-height: 1rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.right-sidebar__toc-item--active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: var(--site-accent);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
196
components/site/SiteAnnouncementBar.vue
Normal file
196
components/site/SiteAnnouncementBar.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<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 usePublicSiteSettings()
|
||||
|
||||
/** 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>
|
||||
@@ -6,11 +6,15 @@ const userMenuToggleRef = ref(null)
|
||||
const searchOpen = ref(false)
|
||||
const member = ref(null)
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'sori.studio',
|
||||
logoUrl: ''
|
||||
})
|
||||
const { data: siteSettings } = await usePublicSiteSettings()
|
||||
|
||||
/**
|
||||
* 현재 회원이 공개 헤더에서 관리자 화면으로 이동할 수 있는지 확인한다.
|
||||
* @returns {boolean} 관리자 화면 이동 가능 여부
|
||||
*/
|
||||
const canOpenSiteAdmin = computed(() => {
|
||||
const role = String(member.value?.role || '').toLowerCase()
|
||||
return Boolean(member.value?.isAdmin) || role === 'owner' || role === 'admin'
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -66,7 +70,7 @@ const toggleUserMenu = () => {
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
member.value = await $fetch('/api/auth/me?optional=1')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
@@ -152,7 +156,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 +171,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 +181,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 +200,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 +228,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 +267,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 || '방문자' }}
|
||||
</div>
|
||||
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
|
||||
{{ member.email }}
|
||||
@@ -264,6 +276,12 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<template v-if="member">
|
||||
<NuxtLink v-if="canOpenSiteAdmin" class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/admin" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="h-5 w-5" aria-hidden="true">
|
||||
<path d="M160-120q-33 0-56.5-23.5T80-200v-480q0-33 23.5-56.5T160-760h240l80 80h320q33 0 56.5 23.5T880-600v400q0 33-23.5 56.5T800-120H160Zm0-80h640v-400H447l-80-80H160v480Zm280-80h80v-120h120v-80H520v-120h-80v120H320v80h120v120ZM160-200v-480 480Z" />
|
||||
</svg>
|
||||
<span>사이트 관리</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/settings" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
|
||||
@@ -287,7 +305,7 @@ onBeforeUnmount(() => {
|
||||
<path d="M15 9l-6 6" />
|
||||
<path d="M15 15v-6h-6" />
|
||||
</svg>
|
||||
<span>Sign up</span>
|
||||
<span>회원가입</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
|
||||
@@ -296,7 +314,7 @@ onBeforeUnmount(() => {
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>Sign in</span>
|
||||
<span>로그인</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
145
components/site/SitePagination.vue
Normal file
145
components/site/SitePagination.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '페이지'
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const normalizedTotalPages = computed(() => Math.max(1, Number(props.totalPages) || 1))
|
||||
const currentPage = computed(() => Math.max(1, Math.min(Number(props.page) || 1, normalizedTotalPages.value)))
|
||||
const previousPage = computed(() => Math.max(1, currentPage.value - 1))
|
||||
const nextPage = computed(() => Math.min(normalizedTotalPages.value, currentPage.value + 1))
|
||||
const canGoPrevious = computed(() => currentPage.value > 1)
|
||||
const canGoNext = computed(() => currentPage.value < normalizedTotalPages.value)
|
||||
|
||||
/**
|
||||
* 연속 페이지 번호를 만든다.
|
||||
* @param {number} start - 시작 페이지
|
||||
* @param {number} end - 끝 페이지
|
||||
* @returns {Array<number>} 페이지 번호 목록
|
||||
*/
|
||||
const range = (start, end) => {
|
||||
const pages = []
|
||||
|
||||
for (let page = start; page <= end; page += 1) {
|
||||
pages.push(page)
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
const pageItems = computed(() => {
|
||||
const total = normalizedTotalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
if (total <= 8) {
|
||||
return range(1, total).map((page) => ({ type: 'page', page }))
|
||||
}
|
||||
|
||||
const pages = new Set([1, total])
|
||||
|
||||
if (current <= 5) {
|
||||
range(1, Math.min(total - 1, Math.max(4, current + 3))).forEach((page) => pages.add(page))
|
||||
} else if (current >= total - 4) {
|
||||
range(Math.max(2, total - 7), total).forEach((page) => pages.add(page))
|
||||
} else {
|
||||
range(Math.max(2, current - 3), Math.min(total - 1, current + 3)).forEach((page) => pages.add(page))
|
||||
}
|
||||
|
||||
const sortedPages = [...pages].sort((left, right) => left - right)
|
||||
const items = []
|
||||
|
||||
sortedPages.forEach((page, index) => {
|
||||
const previous = sortedPages[index - 1]
|
||||
|
||||
if (previous && page - previous > 1) {
|
||||
items.push({
|
||||
type: 'ellipsis',
|
||||
key: `ellipsis-${previous}-${page}`
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'page',
|
||||
page
|
||||
})
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
/**
|
||||
* 페이지 링크 대상을 만든다.
|
||||
* @param {number} page - 페이지 번호
|
||||
* @returns {Object} Nuxt 링크 대상
|
||||
*/
|
||||
const getPageLinkTo = (page) => ({
|
||||
path: props.basePath,
|
||||
query: page > 1 ? { page } : {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="site-pagination site-section" :aria-label="label">
|
||||
<div class="site-pagination__body site-section-body mx-auto flex max-w-[720px] items-center justify-start gap-3 overflow-x-auto text-sm sm:justify-center sm:gap-4">
|
||||
<NuxtLink
|
||||
class="site-pagination__arrow inline-flex size-10 items-center justify-center rounded-full border border-[var(--site-line)] text-[var(--site-muted)] transition hover:border-[var(--site-soft)] hover:text-[var(--site-text)]"
|
||||
:class="!canGoPrevious ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPageLinkTo(previousPage)"
|
||||
:aria-disabled="!canGoPrevious"
|
||||
:aria-label="`${label} 이전으로 이동`"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
|
||||
<ol class="site-pagination__pages flex min-w-max items-center justify-center gap-1 sm:gap-2">
|
||||
<li v-for="item in pageItems" :key="item.type === 'page' ? `page-${item.page}` : item.key">
|
||||
<span
|
||||
v-if="item.type === 'ellipsis'"
|
||||
class="site-pagination__ellipsis inline-flex min-w-9 items-center justify-center px-1 text-base font-semibold text-[var(--site-muted)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-else
|
||||
class="site-pagination__page inline-flex min-w-9 items-center justify-center rounded-full px-2 py-2 text-base font-semibold text-[var(--site-muted)] transition hover:text-[var(--site-text)]"
|
||||
:class="item.page === currentPage ? 'site-pagination__page--current pointer-events-none !bg-[var(--site-accent)] !text-white shadow-sm' : ''"
|
||||
:to="getPageLinkTo(item.page)"
|
||||
:aria-current="item.page === currentPage ? 'page' : undefined"
|
||||
:aria-label="`${item.page}페이지로 이동`"
|
||||
>
|
||||
{{ item.page }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<NuxtLink
|
||||
class="site-pagination__arrow inline-flex size-10 items-center justify-center rounded-full border border-[var(--site-line)] text-[var(--site-muted)] transition hover:border-[var(--site-soft)] hover:text-[var(--site-text)]"
|
||||
:class="!canGoNext ? 'pointer-events-none opacity-35' : ''"
|
||||
:to="getPageLinkTo(nextPage)"
|
||||
:aria-disabled="!canGoNext"
|
||||
:aria-label="`${label} 다음으로 이동`"
|
||||
>
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
/**
|
||||
* @typedef {{ name: string, slug: string }} SearchTagHit
|
||||
* @typedef {{ slug: string, title: string, excerpt: string }} SearchPostHit
|
||||
* @typedef {{ slug: string, title: string, excerpt: string }} SearchPageHit
|
||||
*/
|
||||
|
||||
const open = defineModel({ type: Boolean, default: false })
|
||||
@@ -14,6 +15,16 @@ const isComposing = ref(false)
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
let debounceTimer = null
|
||||
|
||||
/**
|
||||
* 검색에 사용할 입력값을 정규화한다.
|
||||
* @param {string} value - 원본 입력값
|
||||
* @returns {string} 정규화된 검색어
|
||||
*/
|
||||
const normalizeSearchQuery = (value) => String(value ?? '')
|
||||
.normalize('NFC')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
/**
|
||||
* 입력값을 디바운스로 검색어에 반영한다.
|
||||
* @param {string} value - 현재 입력값
|
||||
@@ -22,7 +33,7 @@ let debounceTimer = null
|
||||
const scheduleDebouncedQuery = (value) => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debouncedQuery.value = value
|
||||
debouncedQuery.value = normalizeSearchQuery(value)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
@@ -36,7 +47,11 @@ const { data, pending } = await useFetch('/api/search', {
|
||||
key: fetchKey,
|
||||
query: { q: debouncedQuery },
|
||||
watch: [debouncedQuery],
|
||||
default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) })
|
||||
default: () => ({
|
||||
tags: /** @type {SearchTagHit[]} */ ([]),
|
||||
posts: /** @type {SearchPostHit[]} */ ([]),
|
||||
pages: /** @type {SearchPageHit[]} */ ([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -54,7 +69,7 @@ const escapeRegExp = (value) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
|
||||
*/
|
||||
const highlightParts = (text, needle) => {
|
||||
const base = String(text ?? '')
|
||||
const q = String(needle ?? '').trim()
|
||||
const q = normalizeSearchQuery(needle)
|
||||
if (!q) {
|
||||
return [{ text: base, hit: false }]
|
||||
}
|
||||
@@ -110,32 +125,36 @@ const clearQuery = () => {
|
||||
*/
|
||||
const onCompositionStart = () => {
|
||||
isComposing.value = true
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
/**
|
||||
* IME 조합 중에도 입력창 value를 기반으로 검색어를 갱신한다.
|
||||
* 일부 환경에서는 v-model 업데이트가 조합 종료까지 지연될 수 있다.
|
||||
* @param {CompositionEvent} event - 조합 업데이트 이벤트
|
||||
* 입력 이벤트에서 화면에 보이는 검색어를 갱신한다.
|
||||
* @param {InputEvent | CompositionEvent} event - 입력 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionUpdate = (event) => {
|
||||
const onInput = (event) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return
|
||||
}
|
||||
const value = target.value
|
||||
query.value = value
|
||||
scheduleDebouncedQuery(value)
|
||||
query.value = target.value
|
||||
scheduleDebouncedQuery(target.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신)
|
||||
* @param {CompositionEvent} event - 조합 종료 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionEnd = () => {
|
||||
const onCompositionEnd = (event) => {
|
||||
isComposing.value = false
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement) {
|
||||
query.value = target.value
|
||||
}
|
||||
clearTimeout(debounceTimer)
|
||||
debouncedQuery.value = query.value
|
||||
debouncedQuery.value = normalizeSearchQuery(query.value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,8 +227,9 @@ onBeforeUnmount(() => {
|
||||
class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]"
|
||||
placeholder="글 제목, 본문, 태그 검색"
|
||||
@compositionstart="onCompositionStart"
|
||||
@compositionupdate="onCompositionUpdate"
|
||||
@compositionupdate="onInput"
|
||||
@compositionend="onCompositionEnd"
|
||||
@input="onInput"
|
||||
/>
|
||||
<button type="button" class="site-search-modal__cancel shrink-0 rounded-md px-2 py-1 text-sm text-[var(--site-soft)] hover:text-[var(--site-text)] sm:hidden" @click="open = false">
|
||||
취소
|
||||
@@ -218,7 +238,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="site-search-modal__body min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 sm:px-5 sm:py-4">
|
||||
<p v-if="!query.trim()" class="site-search-modal__hint text-sm text-[var(--site-soft)]">
|
||||
검색어를 입력하면 태그와 게시물이 섹션별로 표시됩니다.
|
||||
검색어를 입력하면 태그, 게시물, 페이지가 섹션별로 표시됩니다.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
@@ -244,7 +264,7 @@ onBeforeUnmount(() => {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__section">
|
||||
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__section mb-6">
|
||||
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||
Posts
|
||||
</h2>
|
||||
@@ -272,7 +292,35 @@ onBeforeUnmount(() => {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
|
||||
<section v-if="(data?.pages?.length ?? 0) > 0" class="site-search-modal__section">
|
||||
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||
Pages
|
||||
</h2>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="page in data.pages" :key="page.slug">
|
||||
<NuxtLink
|
||||
class="site-search-modal__page-link block rounded-md px-2 py-2 transition-colors hover:bg-[var(--site-panel)]"
|
||||
:to="`/pages/${page.slug}/`"
|
||||
@click="open = false"
|
||||
>
|
||||
<div class="text-[15px] font-semibold leading-snug text-[var(--site-text)]">
|
||||
<template v-for="(seg, i) in highlightParts(page.title, query)" :key="`pt-${page.slug}-${i}`">
|
||||
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-text)]">{{ seg.text }}</mark>
|
||||
<span v-else>{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="clipExcerpt(page.excerpt)" class="mt-0.5 line-clamp-2 text-sm leading-relaxed text-[var(--site-soft)]">
|
||||
<template v-for="(seg, i) in highlightParts(clipExcerpt(page.excerpt), query)" :key="`pe-${page.slug}-${i}`">
|
||||
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-muted)]">{{ seg.text }}</mark>
|
||||
<span v-else>{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0 && (data?.pages?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
|
||||
일치하는 결과가 없습니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
48
components/site/SiteTopChrome.vue
Normal file
48
components/site/SiteTopChrome.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
let resizeObserver = null
|
||||
|
||||
/**
|
||||
* 상단 크롬(어나운스 바·헤더) 높이를 CSS 변수로 반영한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncTopChromeHeight = () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const chrome = document.querySelector('.site-top-chrome')
|
||||
const height = chrome instanceof HTMLElement ? chrome.offsetHeight : 57
|
||||
document.documentElement.style.setProperty('--site-top-chrome-height', `${height}px`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncTopChromeHeight()
|
||||
window.addEventListener('resize', syncTopChromeHeight)
|
||||
|
||||
const chrome = document.querySelector('.site-top-chrome')
|
||||
if (chrome instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncTopChromeHeight()
|
||||
})
|
||||
resizeObserver.observe(chrome)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', syncTopChromeHeight)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
document.documentElement.style.removeProperty('--site-top-chrome-height')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-top-chrome sticky top-0 z-20 shrink-0">
|
||||
<SiteAnnouncementBar />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
54
composables/createPostSummary.js
Normal file
54
composables/createPostSummary.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/** @type {RegExp} 요약에서 표시 텍스트만 남길 인라인 색상 문법 */
|
||||
const INLINE_COLOR_SUMMARY_RE = /::(?:gray|blue|green|yellow|red|purple)\[((?:\\\]|[^\]\n])*)\]/g
|
||||
|
||||
/**
|
||||
* 인라인 색상 문법을 요약용 일반 텍스트로 바꾼다.
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 색상 토큰을 제거한 문자열
|
||||
*/
|
||||
const stripInlineColorMarkdown = (value) => String(value || '').replace(
|
||||
INLINE_COLOR_SUMMARY_RE,
|
||||
(_match, text) => String(text || '').replaceAll('\\]', ']')
|
||||
)
|
||||
|
||||
/**
|
||||
* 게시물 요약 또는 본문에서 목록·메타용 짧은 텍스트를 만든다.
|
||||
* @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 = stripInlineColorMarkdown(source)
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/:::[\s\S]*?:::/g, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
||||
.replace(/https?:\/\/\S+/g, ' ')
|
||||
.replace(/(^|\s)::(?:gray|blue|green|yellow|red|purple)(?=\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
|
||||
}
|
||||
|
||||
340
composables/useAdminFeaturedImagePicker.js
Normal file
340
composables/useAdminFeaturedImagePicker.js
Normal file
@@ -0,0 +1,340 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
isPageCoverThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl,
|
||||
withGeneratedImageCacheVersion
|
||||
} from '../lib/post-card-thumbnail-url.js'
|
||||
|
||||
/**
|
||||
* 자동 생성 파생 이미지 URL인지 확인한다.
|
||||
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
|
||||
* @returns {boolean} 자동 생성 이미지이면 true
|
||||
*/
|
||||
const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|
||||
|| isPostBodyThumbnailUrl(imageUrl)
|
||||
|| isPageCoverThumbnailUrl(imageUrl)
|
||||
|
||||
/**
|
||||
* 관리자 게시물 폼의 대표 이미지 선택 모달 상태를 만든다.
|
||||
* @param {Object} options - 대표 이미지 선택 옵션
|
||||
* @param {Object} options.form - 게시물 폼 reactive 객체
|
||||
* @param {number} [options.pageSize=50] - 미디어 페이지 크기
|
||||
* @returns {Object} 대표 이미지 선택 상태와 이벤트 처리기
|
||||
*/
|
||||
export const useAdminFeaturedImagePicker = ({
|
||||
form,
|
||||
pageSize = 50
|
||||
}) => {
|
||||
const mediaItems = ref([])
|
||||
const isMediaPickerOpen = ref(false)
|
||||
const mediaPickerTarget = ref('featuredImage')
|
||||
const isLoadingMedia = ref(false)
|
||||
const isUploadingFeaturedImage = ref(false)
|
||||
const activeMediaPickerTab = ref('upload')
|
||||
const selectedMediaPickerUrl = ref('')
|
||||
const mediaSearchQuery = ref('')
|
||||
const {
|
||||
displayValue: mediaSearchInput,
|
||||
onInput: onMediaSearchInput,
|
||||
onCompositionEnd: onMediaSearchCompositionEnd
|
||||
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
|
||||
const mediaPickerPage = ref(1)
|
||||
const mediaPickerPageSize = pageSize
|
||||
const mediaPickerPagination = ref({
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: 0,
|
||||
pageCount: 1
|
||||
})
|
||||
|
||||
/**
|
||||
* 미디어 선택 페이지네이션을 초기값으로 되돌린다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const resetMediaPickerPagination = () => {
|
||||
mediaPickerPage.value = 1
|
||||
mediaPickerPagination.value = {
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: 0,
|
||||
pageCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 라이브러리 목록을 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMediaItems = async () => {
|
||||
isLoadingMedia.value = true
|
||||
|
||||
try {
|
||||
const response = await $fetch('/admin/api/media', {
|
||||
query: {
|
||||
paged: '1',
|
||||
tab: 'library',
|
||||
page: mediaPickerPage.value,
|
||||
pageSize: mediaPickerPageSize,
|
||||
kind: 'image',
|
||||
search: mediaSearchQuery.value.trim() || undefined
|
||||
}
|
||||
})
|
||||
|
||||
mediaItems.value = response?.items || []
|
||||
mediaPickerPagination.value = response?.pagination || {
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: mediaItems.value.length,
|
||||
pageCount: 1
|
||||
}
|
||||
mediaPickerPage.value = mediaPickerPagination.value.page || 1
|
||||
} finally {
|
||||
isLoadingMedia.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창을 연다.
|
||||
* @param {string} target - 선택 값을 적용할 폼 필드
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const openMediaPicker = async (target = 'featuredImage') => {
|
||||
mediaPickerTarget.value = target
|
||||
activeMediaPickerTab.value = 'upload'
|
||||
mediaSearchQuery.value = ''
|
||||
resetMediaPickerPagination()
|
||||
selectedMediaPickerUrl.value = isGeneratedThumbnailUrl(form[target]) ? '' : form[target] || ''
|
||||
isMediaPickerOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeMediaPicker = () => {
|
||||
if (isUploadingFeaturedImage.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isMediaPickerOpen.value = false
|
||||
mediaItems.value = []
|
||||
mediaSearchQuery.value = ''
|
||||
resetMediaPickerPagination()
|
||||
}
|
||||
|
||||
const featuredImageMediaItems = computed(() => mediaItems.value.filter((item) => (
|
||||
item?.kind === 'image' && !isGeneratedThumbnailUrl(item.url)
|
||||
)))
|
||||
|
||||
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
|
||||
|
||||
const mediaPickerRangeLabel = computed(() => {
|
||||
const total = Number(mediaPickerPagination.value.total) || 0
|
||||
|
||||
if (total <= 0) {
|
||||
return '0개'
|
||||
}
|
||||
|
||||
const page = Math.min(Math.max(Number(mediaPickerPagination.value.page) || mediaPickerPage.value, 1), mediaPickerPageCount.value)
|
||||
const start = ((page - 1) * mediaPickerPageSize) + 1
|
||||
const end = Math.min(start + mediaPickerPageSize - 1, total)
|
||||
|
||||
return `${total}개 중 ${start}-${end}개`
|
||||
})
|
||||
|
||||
const mediaPickerPageItems = computed(() => {
|
||||
const total = mediaPickerPageCount.value
|
||||
const current = Math.min(Math.max(Number(mediaPickerPage.value) || 1, 1), total)
|
||||
const pages = new Set([1, total, current])
|
||||
|
||||
for (let page = current - 2; page <= current + 2; page += 1) {
|
||||
pages.add(page)
|
||||
}
|
||||
|
||||
if (current <= 3) {
|
||||
pages.add(2)
|
||||
pages.add(3)
|
||||
pages.add(4)
|
||||
}
|
||||
|
||||
if (current >= total - 2) {
|
||||
pages.add(total - 3)
|
||||
pages.add(total - 2)
|
||||
pages.add(total - 1)
|
||||
}
|
||||
|
||||
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
|
||||
return orderedPages.reduce((items, page, index) => {
|
||||
const previousPage = orderedPages[index - 1]
|
||||
|
||||
if (previousPage && page - previousPage > 1) {
|
||||
items.push(`ellipsis-${previousPage}-${page}`)
|
||||
}
|
||||
|
||||
items.push(page)
|
||||
return items
|
||||
}, [])
|
||||
})
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 모달 페이지를 변경한다.
|
||||
* @param {number} page - 이동할 페이지
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const setMediaPickerPage = async (page) => {
|
||||
mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value)
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {string} 미리보기 URL
|
||||
*/
|
||||
const getFeaturedMediaPreviewUrl = (item) => {
|
||||
const url = String(item?.url || '')
|
||||
|
||||
if (item?.bodyThumbnailStatus?.hasThumbnail) {
|
||||
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
|
||||
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
watch(mediaSearchQuery, async () => {
|
||||
if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
mediaPickerPage.value = 1
|
||||
await fetchMediaItems()
|
||||
})
|
||||
|
||||
watch(activeMediaPickerTab, async (tab) => {
|
||||
if (!isMediaPickerOpen.value || tab !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
mediaPickerPage.value = 1
|
||||
await fetchMediaItems()
|
||||
})
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 상태를 변경한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectPickedImage = (item) => {
|
||||
selectedMediaPickerUrl.value = item.url
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 대표 이미지를 적용한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyPickedImage = () => {
|
||||
if (!selectedMediaPickerUrl.value || isGeneratedThumbnailUrl(selectedMediaPickerUrl.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
form[mediaPickerTarget.value] = selectedMediaPickerUrl.value
|
||||
closeMediaPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지를 삭제한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeFeaturedImage = () => {
|
||||
form.featuredImage = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 파일을 업로드한다.
|
||||
* @param {File} file - 업로드 파일
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFeaturedImageFile = async (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('files', file)
|
||||
isUploadingFeaturedImage.value = true
|
||||
|
||||
try {
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
selectedMediaPickerUrl.value = result.files?.[0]?.url || ''
|
||||
await fetchMediaItems()
|
||||
activeMediaPickerTab.value = 'library'
|
||||
} finally {
|
||||
isUploadingFeaturedImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 파일 입력을 처리한다.
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFeaturedImage = async (event) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadFeaturedImageFile(files[0])
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 드롭 업로드를 처리한다.
|
||||
* @param {DragEvent} event - 드롭 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const dropFeaturedImage = async (event) => {
|
||||
const files = event.dataTransfer?.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await uploadFeaturedImageFile(files[0])
|
||||
}
|
||||
|
||||
return {
|
||||
activeMediaPickerTab,
|
||||
applyPickedImage,
|
||||
closeMediaPicker,
|
||||
dropFeaturedImage,
|
||||
featuredImageMediaItems,
|
||||
getFeaturedMediaPreviewUrl,
|
||||
isLoadingMedia,
|
||||
isMediaPickerOpen,
|
||||
isUploadingFeaturedImage,
|
||||
mediaPickerPage,
|
||||
mediaPickerPageCount,
|
||||
mediaPickerPageItems,
|
||||
mediaPickerRangeLabel,
|
||||
mediaSearchInput,
|
||||
mediaSearchQuery,
|
||||
onMediaSearchCompositionEnd,
|
||||
onMediaSearchInput,
|
||||
openMediaPicker,
|
||||
removeFeaturedImage,
|
||||
selectPickedImage,
|
||||
selectedMediaPickerUrl,
|
||||
setMediaPickerPage,
|
||||
uploadFeaturedImage
|
||||
}
|
||||
}
|
||||
264
composables/useAdminMarkdownBlockActions.js
Normal file
264
composables/useAdminMarkdownBlockActions.js
Normal file
@@ -0,0 +1,264 @@
|
||||
import { nextTick } from 'vue'
|
||||
import {
|
||||
createCalloutOptionsReplacementPlan,
|
||||
createCodeOptionsReplacementPlan,
|
||||
createEmbedUrlReplacementPlan,
|
||||
createGalleryImageMovePlan,
|
||||
createGalleryImagesAppendPlan,
|
||||
createLiveInlineColorValue,
|
||||
createMediaImageRemovePlan,
|
||||
createMediaImagesReplacementPlan,
|
||||
createMediaImageUpdatePlan,
|
||||
createMediaUseAltPatch,
|
||||
createQuoteBackgroundValue,
|
||||
createSourceInlineColorValue,
|
||||
createToggleOptionsReplacementPlan
|
||||
} from '../lib/markdown-editor-block-options.js'
|
||||
|
||||
/**
|
||||
* 관리자 Markdown 블록 패널의 옵션 적용 함수를 만든다.
|
||||
* @param {Object} options - 블록 액션 옵션
|
||||
* @param {import('vue').Ref<string>} options.value - Markdown 본문 ref
|
||||
* @param {import('vue').Ref<Object|null>} options.activeMediaBlock - 현재 미디어 블록 ref
|
||||
* @param {import('vue').Ref<Object|null>} options.activeBlockContext - 현재 활성 블록 ref
|
||||
* @param {import('vue').Ref<Object|null>} options.inlineColorSelectionContext - 인라인 색상 선택 컨텍스트 ref
|
||||
* @param {(plan: Object|null, focusEditor?: boolean) => boolean} options.applyLineReplacementPlan - 줄 교체 계획 적용 함수
|
||||
* @param {() => void} options.ensureBlockPanelEngaged - 블록 패널 유지 함수
|
||||
* @param {(images: Array<Object>) => void} options.insertGallery - 갤러리 삽입 함수
|
||||
* @param {(start: number, end?: number, options?: Object) => void} options.setTextareaSelection - textarea 선택 범위 설정 함수
|
||||
* @param {(focus: Object) => void} options.requestPreviewFocus - 미리보기 포커스 예약 함수
|
||||
* @param {(line: number, column?: number, align?: string, offset?: number, block?: string) => void} options.focusPreviewEditableAtLine - 미리보기 줄 포커스 함수
|
||||
* @returns {Object} 블록 패널 옵션 적용 함수
|
||||
*/
|
||||
export const useAdminMarkdownBlockActions = (options) => {
|
||||
/**
|
||||
* 줄 교체 계획을 블록 패널 유지와 함께 적용한다.
|
||||
* @param {Object|null} plan - 줄 교체 계획
|
||||
* @param {boolean} [engage=true] - 패널 유지 여부
|
||||
* @returns {boolean} 적용 여부
|
||||
*/
|
||||
const applyPanelLineReplacementPlan = (plan, engage = true) => {
|
||||
if (!plan) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (engage) {
|
||||
options.ensureBlockPanelEngaged()
|
||||
}
|
||||
|
||||
return options.applyLineReplacementPlan(plan, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 미디어 블록을 이미지 목록 기준으로 다시 작성한다.
|
||||
* @param {Array<{ alt: string, url: string, width?: string }>} images - 이미지 목록
|
||||
* @returns {void}
|
||||
*/
|
||||
const replaceActiveMediaImages = (images) => {
|
||||
options.applyLineReplacementPlan(
|
||||
createMediaImagesReplacementPlan(options.activeMediaBlock.value, images),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 미디어 블록의 특정 이미지를 수정한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Partial<{ alt: string, url: string, width: string, pixelWidth: number|null }>} patch - 변경 값
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveMediaImage = (imageIndex, patch) => {
|
||||
applyPanelLineReplacementPlan(
|
||||
createMediaImageUpdatePlan(options.activeMediaBlock.value, imageIndex, patch)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 미디어 이미지의 파일명 캡션 사용 여부를 바꾼다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {boolean} enabled - 파일명 캡션 사용 여부
|
||||
* @returns {void}
|
||||
*/
|
||||
const setActiveMediaUseAlt = (imageIndex, enabled) => {
|
||||
const image = options.activeMediaBlock.value?.images[imageIndex]
|
||||
const patch = createMediaUseAltPatch(image, enabled)
|
||||
|
||||
if (patch) {
|
||||
updateActiveMediaImage(imageIndex, patch)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 갤러리 이미지 순서를 바꾼다.
|
||||
* @param {number} imageIndex - 이동할 이미지 인덱스
|
||||
* @param {-1|1} direction - 이동 방향
|
||||
* @returns {void}
|
||||
*/
|
||||
const moveActiveGalleryImage = (imageIndex, direction) => {
|
||||
applyPanelLineReplacementPlan(
|
||||
createGalleryImageMovePlan(options.activeMediaBlock.value, imageIndex, direction),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 미디어 블록에서 이미지를 삭제한다.
|
||||
* @param {number} imageIndex - 삭제할 이미지 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeActiveMediaImage = (imageIndex) => {
|
||||
applyPanelLineReplacementPlan(
|
||||
createMediaImageRemovePlan(options.activeMediaBlock.value, imageIndex),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 갤러리에 이미지를 추가한다.
|
||||
* @param {Array<{ url: string, alt?: string, width?: string }>} images - 추가 이미지 목록
|
||||
* @returns {void}
|
||||
*/
|
||||
const appendImagesToActiveGallery = (images) => {
|
||||
const plan = createGalleryImagesAppendPlan(options.activeMediaBlock.value, images)
|
||||
|
||||
if (!plan) {
|
||||
options.insertGallery(images)
|
||||
return
|
||||
}
|
||||
|
||||
options.applyLineReplacementPlan(plan, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 링크·iframe·HTML 블록 값을 수정한다.
|
||||
* @param {string} url - 블록 값
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveEmbedUrl = (url) => {
|
||||
applyPanelLineReplacementPlan(
|
||||
createEmbedUrlReplacementPlan(options.activeBlockContext.value, url)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인용 블록 배경색을 수정한다.
|
||||
* @param {string} background - 배경색 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveQuoteBackground = (background) => {
|
||||
const nextValue = createQuoteBackgroundValue({
|
||||
value: options.value.value,
|
||||
block: options.activeBlockContext.value,
|
||||
background
|
||||
})
|
||||
|
||||
if (nextValue === null) {
|
||||
return
|
||||
}
|
||||
|
||||
options.ensureBlockPanelEngaged()
|
||||
options.value.value = nextValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 콜아웃 블록 옵션을 수정한다.
|
||||
* @param {Partial<{ calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string, title: string }>} patch - 변경 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveCalloutOptions = (patch = {}) => {
|
||||
applyPanelLineReplacementPlan(
|
||||
createCalloutOptionsReplacementPlan(options.activeBlockContext.value, patch)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 코드 블록 옵션을 수정한다.
|
||||
* @param {Partial<{ language: string, showLineNumbers: boolean }>} patch - 변경 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveCodeOptions = (patch = {}) => {
|
||||
applyPanelLineReplacementPlan(
|
||||
createCodeOptionsReplacementPlan(options.activeBlockContext.value, patch)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 토글 블록 옵션을 수정한다.
|
||||
* @param {Partial<{ defaultOpen: boolean }>} patch - 변경 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveToggleOptions = (patch = {}) => {
|
||||
applyPanelLineReplacementPlan(
|
||||
createToggleOptionsReplacementPlan(options.activeBlockContext.value, patch)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 선택된 텍스트에 인라인 색상을 적용한다.
|
||||
* @param {string} color - 색상 이름
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveInlineColor = (color) => {
|
||||
const context = options.inlineColorSelectionContext.value
|
||||
|
||||
if (!context || context.kind !== 'inline-color') {
|
||||
return
|
||||
}
|
||||
|
||||
options.ensureBlockPanelEngaged()
|
||||
|
||||
const sourceResult = createSourceInlineColorValue({
|
||||
value: options.value.value,
|
||||
context,
|
||||
color
|
||||
})
|
||||
|
||||
if (sourceResult) {
|
||||
options.value.value = sourceResult.value
|
||||
options.inlineColorSelectionContext.value = null
|
||||
options.setTextareaSelection(sourceResult.selectionStart, sourceResult.selectionEnd, { preserveScroll: true })
|
||||
return
|
||||
}
|
||||
|
||||
const liveResult = createLiveInlineColorValue({
|
||||
value: options.value.value,
|
||||
context,
|
||||
color
|
||||
})
|
||||
|
||||
if (!liveResult) {
|
||||
return
|
||||
}
|
||||
|
||||
options.value.value = liveResult.value
|
||||
options.inlineColorSelectionContext.value = null
|
||||
options.requestPreviewFocus({
|
||||
line: liveResult.focusLine,
|
||||
offset: liveResult.focusOffset
|
||||
})
|
||||
nextTick(() => {
|
||||
options.focusPreviewEditableAtLine(
|
||||
liveResult.focusLine,
|
||||
0,
|
||||
'auto',
|
||||
liveResult.focusOffset,
|
||||
'nearest'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
appendImagesToActiveGallery,
|
||||
moveActiveGalleryImage,
|
||||
removeActiveMediaImage,
|
||||
replaceActiveMediaImages,
|
||||
setActiveMediaUseAlt,
|
||||
updateActiveCalloutOptions,
|
||||
updateActiveCodeOptions,
|
||||
updateActiveEmbedUrl,
|
||||
updateActiveInlineColor,
|
||||
updateActiveMediaImage,
|
||||
updateActiveQuoteBackground,
|
||||
updateActiveToggleOptions
|
||||
}
|
||||
}
|
||||
332
composables/useAdminMarkdownBlockPanel.js
Normal file
332
composables/useAdminMarkdownBlockPanel.js
Normal file
@@ -0,0 +1,332 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 지정 줄이 블록 컨텍스트 범위 안에 있는지 확인한다.
|
||||
* @param {Object|null} context - 블록 컨텍스트
|
||||
* @param {number} lineIndex - 현재 논리 줄
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isLineInsideBlockContext = (context, lineIndex) => {
|
||||
if (!context) {
|
||||
return false
|
||||
}
|
||||
|
||||
const startLine = Number(context.startLine)
|
||||
const endLine = Number(context.endLine)
|
||||
|
||||
return Number.isInteger(startLine)
|
||||
&& Number.isInteger(endLine)
|
||||
&& lineIndex >= startLine
|
||||
&& lineIndex <= endLine
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 Markdown 에디터의 블록 설정 패널 표시 상태를 만든다.
|
||||
* @param {Object} options - 블록 패널 옵션
|
||||
* @param {import('vue').Ref<'write'|'preview'|string>} options.activeMode - 현재 에디터 모드
|
||||
* @param {import('vue').Ref<string>} options.value - Markdown 본문
|
||||
* @param {import('vue').Ref<string|null>} options.writeModeContextValue - 작성 모드 최신 본문
|
||||
* @param {import('vue').Ref<number>} options.activeLogicalLineIndex - 현재 논리 줄
|
||||
* @param {import('vue').Ref<boolean>} options.isMediaPickerOpen - 미디어 선택 모달 표시 여부
|
||||
* @param {(value: string, lineIndex: number) => Object|null} options.resolveBlockContext - 블록 컨텍스트 계산 함수
|
||||
* @param {() => Object|null} options.getInlineColorSelectionContext - 인라인 색상 선택 컨텍스트 계산 함수
|
||||
* @param {(payload: { open: boolean, panel: Object|null }) => void} options.emitBlockPanel - 패널 상태 전달 함수
|
||||
* @returns {Object} 블록 패널 상태와 이벤트
|
||||
*/
|
||||
export const useAdminMarkdownBlockPanel = (options) => {
|
||||
/** textarea 포커스 여부 */
|
||||
const isTextareaFocused = ref(false)
|
||||
/** 블록 패널에 포커스가 있거나 패널 편집을 유지해야 하는지 여부 */
|
||||
const isBlockPanelEngaged = ref(false)
|
||||
/** 한글 등 IME 조합 입력 중 여부 */
|
||||
const isTextComposing = ref(false)
|
||||
/** 조합 입력 중 패널 닫힘을 방지하기 위한 마지막 블록 컨텍스트 */
|
||||
const lastStableBlockContext = ref(null)
|
||||
/** @type {import('vue').Ref<{ kind: 'inline-color', mode: 'source'|'live', start?: number, end?: number, sourceLine?: number, startOffset?: number, endOffset?: number, bodyText?: string, text: string }|null>} 선택 텍스트 색상 패널 컨텍스트 */
|
||||
const inlineColorSelectionContext = ref(null)
|
||||
let blockPanelFocusTimer = null
|
||||
|
||||
/**
|
||||
* 현재 포커스가 블록 설정 패널 안에 있는지 확인한다.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isFocusInBlockPanel = () => Boolean(
|
||||
typeof document !== 'undefined'
|
||||
&& document.activeElement?.closest?.('.admin-editor-block-panel')
|
||||
)
|
||||
|
||||
/**
|
||||
* 현재 포커스가 미디어 선택 모달 안에 있는지 확인한다.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isFocusInMediaPicker = () => Boolean(
|
||||
typeof document !== 'undefined'
|
||||
&& document.activeElement?.closest?.('.admin-markdown-editor__media-modal')
|
||||
)
|
||||
|
||||
/**
|
||||
* 커서 줄 기준 활성 블록(이미지·갤러리·임베드·인용)
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
const activeBlockContext = computed(() => options.resolveBlockContext(
|
||||
options.writeModeContextValue.value ?? options.value.value,
|
||||
options.activeLogicalLineIndex.value
|
||||
))
|
||||
|
||||
/** @deprecated 내부 호환 alias */
|
||||
const activeMediaBlock = activeBlockContext
|
||||
|
||||
/**
|
||||
* IME 조합 중에는 일시적으로 블록 판별이 비어도 직전 패널을 유지한다.
|
||||
* @returns {Object|null} 패널 표시용 블록 컨텍스트
|
||||
*/
|
||||
const visibleBlockContext = computed(() => {
|
||||
if (inlineColorSelectionContext.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (activeBlockContext.value) {
|
||||
return activeBlockContext.value
|
||||
}
|
||||
|
||||
if (isTextComposing.value
|
||||
&& isLineInsideBlockContext(lastStableBlockContext.value, options.activeLogicalLineIndex.value)) {
|
||||
return lastStableBlockContext.value
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
/**
|
||||
* 오른쪽 설정 패널에 표시할 현재 컨텍스트를 반환한다.
|
||||
* @returns {Object|null} 패널 컨텍스트
|
||||
*/
|
||||
const activePanelContext = computed(() => inlineColorSelectionContext.value || visibleBlockContext.value)
|
||||
|
||||
/**
|
||||
* 블록 설정 패널 표시 여부
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isBlockPanelVisible = computed(() => (options.activeMode.value === 'write' || isBlockPanelEngaged.value)
|
||||
&& Boolean(activePanelContext.value)
|
||||
&& (isTextareaFocused.value || isBlockPanelEngaged.value))
|
||||
|
||||
/**
|
||||
* 부모(글 설정 사이드바 오버레이)에 패널 상태를 전달한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncBlockPanelState = () => {
|
||||
options.emitBlockPanel({
|
||||
open: isBlockPanelVisible.value,
|
||||
panel: activePanelContext.value
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널 편집 중 상태를 유지한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const ensureBlockPanelEngaged = () => {
|
||||
window.clearTimeout(blockPanelFocusTimer)
|
||||
isBlockPanelEngaged.value = true
|
||||
syncBlockPanelState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 설정 패널을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeBlockPanel = () => {
|
||||
window.clearTimeout(blockPanelFocusTimer)
|
||||
isBlockPanelEngaged.value = false
|
||||
isTextareaFocused.value = false
|
||||
inlineColorSelectionContext.value = null
|
||||
syncBlockPanelState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 선택 범위를 기준으로 텍스트 색상 패널 컨텍스트를 갱신한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncInlineColorSelectionContext = () => {
|
||||
if (isFocusInBlockPanel() || options.isMediaPickerOpen.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextContext = options.getInlineColorSelectionContext()
|
||||
inlineColorSelectionContext.value = nextContext
|
||||
|
||||
if (nextContext) {
|
||||
isBlockPanelEngaged.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* textarea 포커스 진입 상태를 기록한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleTextareaFocusIn = () => {
|
||||
window.clearTimeout(blockPanelFocusTimer)
|
||||
isTextareaFocused.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* textarea 포커스 이탈 상태를 지연 반영한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleTextareaFocusOut = () => {
|
||||
blockPanelFocusTimer = window.setTimeout(() => {
|
||||
if (options.isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
|
||||
return
|
||||
}
|
||||
|
||||
isTextareaFocused.value = false
|
||||
syncBlockPanelState()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널 포커스 진입(AdminPostForm 오버레이에서 호출)
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBlockPanelFocusIn = () => {
|
||||
window.clearTimeout(blockPanelFocusTimer)
|
||||
isBlockPanelEngaged.value = true
|
||||
syncBlockPanelState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널 포커스 이탈
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBlockPanelFocusOut = () => {
|
||||
blockPanelFocusTimer = window.setTimeout(() => {
|
||||
if (options.isMediaPickerOpen.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
|
||||
return
|
||||
}
|
||||
|
||||
isBlockPanelEngaged.value = false
|
||||
|
||||
if (!isTextareaFocused.value) {
|
||||
syncBlockPanelState()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
/**
|
||||
* IME 조합 입력 시작 시 패널 컨텍스트를 유지한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleTextareaCompositionStart = () => {
|
||||
isTextComposing.value = true
|
||||
|
||||
if (activeBlockContext.value) {
|
||||
lastStableBlockContext.value = activeBlockContext.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IME 조합 입력 종료 상태를 기록한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleTextareaCompositionEnd = () => {
|
||||
isTextComposing.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 모드 포커스 줄을 기준으로 블록 패널을 동기화한다.
|
||||
* @param {number} sourceLine - 원본 마크다운 줄(0-based)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleLiveLineFocus = async (sourceLine) => {
|
||||
const nextLine = Number(sourceLine)
|
||||
|
||||
if (!Number.isInteger(nextLine) || nextLine < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
options.activeLogicalLineIndex.value = nextLine
|
||||
isTextareaFocused.value = false
|
||||
isTextComposing.value = false
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (activeBlockContext.value) {
|
||||
lastStableBlockContext.value = activeBlockContext.value
|
||||
isBlockPanelEngaged.value = true
|
||||
} else {
|
||||
isBlockPanelEngaged.value = false
|
||||
}
|
||||
|
||||
syncBlockPanelState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 모드 포커스가 블록·패널 밖으로 나가면 패널을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleLiveLineBlur = () => {
|
||||
blockPanelFocusTimer = window.setTimeout(() => {
|
||||
if (options.isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
|
||||
return
|
||||
}
|
||||
|
||||
isBlockPanelEngaged.value = false
|
||||
syncBlockPanelState()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널 컨텍스트를 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const resetBlockPanelContext = () => {
|
||||
isBlockPanelEngaged.value = false
|
||||
lastStableBlockContext.value = null
|
||||
syncBlockPanelState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널 타이머를 정리한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearBlockPanelFocusTimer = () => {
|
||||
window.clearTimeout(blockPanelFocusTimer)
|
||||
}
|
||||
|
||||
watch(activeBlockContext, (context) => {
|
||||
if (context) {
|
||||
lastStableBlockContext.value = context
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch([isBlockPanelVisible, activePanelContext], syncBlockPanelState, { deep: true })
|
||||
|
||||
return {
|
||||
isTextareaFocused,
|
||||
isBlockPanelEngaged,
|
||||
isTextComposing,
|
||||
lastStableBlockContext,
|
||||
inlineColorSelectionContext,
|
||||
activeBlockContext,
|
||||
activeMediaBlock,
|
||||
activePanelContext,
|
||||
isBlockPanelVisible,
|
||||
isFocusInBlockPanel,
|
||||
isFocusInMediaPicker,
|
||||
ensureBlockPanelEngaged,
|
||||
closeBlockPanel,
|
||||
syncBlockPanelState,
|
||||
syncInlineColorSelectionContext,
|
||||
handleTextareaFocusIn,
|
||||
handleTextareaFocusOut,
|
||||
handleBlockPanelFocusIn,
|
||||
handleBlockPanelFocusOut,
|
||||
handleTextareaCompositionStart,
|
||||
handleTextareaCompositionEnd,
|
||||
handleLiveLineFocus,
|
||||
handleLiveLineBlur,
|
||||
resetBlockPanelContext,
|
||||
clearBlockPanelFocusTimer
|
||||
}
|
||||
}
|
||||
131
composables/useAdminMarkdownEditorFocusActions.js
Normal file
131
composables/useAdminMarkdownEditorFocusActions.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { nextTick, unref } from 'vue'
|
||||
|
||||
/**
|
||||
* 관리자 Markdown 에디터의 공통 포커스 실행을 관리한다.
|
||||
* @param {Object} options - 포커스 실행 옵션
|
||||
* @param {import('vue').Ref<'write'|'preview'|string>} options.activeMode - 현재 에디터 모드
|
||||
* @param {import('vue').Ref<string>} options.value - Markdown 본문
|
||||
* @param {import('vue').Ref<number>} options.activeLogicalLineIndex - 현재 논리 줄
|
||||
* @param {() => HTMLTextAreaElement|null} options.getWriteTextareaElement - 작성 textarea 반환 함수
|
||||
* @param {(offset: number) => number} options.getLineIndexAtOffset - 오프셋 기준 줄 계산 함수
|
||||
* @param {(lineIndex: number) => number} options.getLineStartOffset - 줄 시작 오프셋 계산 함수
|
||||
* @param {(start: number, end?: number, options?: { preserveScroll?: boolean }) => void} options.setTextareaSelection - textarea 선택 복원 함수
|
||||
* @param {(lineIndex: number) => void} options.focusTextareaAtLine - 지정 줄 textarea 포커스 함수
|
||||
* @param {() => void} options.refreshCaretLogicalLine - 현재 커서 줄 갱신 함수
|
||||
* @param {() => void} options.scrollPreviewToTop - 라이브 미리보기 상단 이동 함수
|
||||
* @param {(line: number, childIndex: number, position: string, offset?: number, scrollAlign?: string) => void} options.focusPreviewEditableAtLine - 라이브 편집 줄 포커스 함수
|
||||
* @returns {{
|
||||
* focusFirstBlock: () => void,
|
||||
* getInitialContentFocusLineIndex: () => number,
|
||||
* ensureContentFocusLineExists: (lineIndex: number) => number,
|
||||
* restoreEditorFocusAfterHistory: (selection?: { start: number, end: number }|null) => void
|
||||
* }} 공통 포커스 액션
|
||||
*/
|
||||
export const useAdminMarkdownEditorFocusActions = (options) => {
|
||||
/**
|
||||
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
|
||||
* @returns {number} 줄 번호(0-based)
|
||||
*/
|
||||
const getInitialContentFocusLineIndex = () => {
|
||||
const lines = String(unref(options.value) ?? '').split('\n')
|
||||
|
||||
if (!lines.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const firstLine = lines[0]?.trim() ?? ''
|
||||
|
||||
if (/^#{1,6}\s+/.test(firstLine)) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 작성 시작 줄이 없으면 빈 줄을 추가한다.
|
||||
* @param {number} lineIndex - 포커스할 줄
|
||||
* @returns {number} 보정된 줄 번호
|
||||
*/
|
||||
const ensureContentFocusLineExists = (lineIndex) => {
|
||||
const value = String(unref(options.value) ?? '')
|
||||
const lines = value.split('\n')
|
||||
|
||||
if (lineIndex < lines.length) {
|
||||
return lineIndex
|
||||
}
|
||||
|
||||
options.value.value = lines.length ? `${value}\n` : ''
|
||||
return lines.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusFirstBlock = () => {
|
||||
const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex())
|
||||
|
||||
if (options.activeMode.value === 'preview') {
|
||||
options.scrollPreviewToTop()
|
||||
|
||||
nextTick(() => {
|
||||
nextTick(() => {
|
||||
options.focusPreviewEditableAtLine(focusLine, 0, 'start')
|
||||
})
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const textarea = options.getWriteTextareaElement()
|
||||
const lineStart = options.getLineStartOffset(focusLine)
|
||||
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(lineStart, lineStart)
|
||||
textarea.scrollTop = 0
|
||||
}
|
||||
|
||||
options.activeLogicalLineIndex.value = focusLine
|
||||
options.refreshCaretLogicalLine()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토리 복원 후 현재 모드에 맞게 포커스를 복원한다.
|
||||
* @param {{ start: number, end: number }|null} [selection=null] - 복원할 선택 영역
|
||||
* @returns {void}
|
||||
*/
|
||||
const restoreEditorFocusAfterHistory = (selection = null) => {
|
||||
const focusLine = selection
|
||||
? options.getLineIndexAtOffset(selection.start)
|
||||
: options.activeLogicalLineIndex.value
|
||||
|
||||
if (options.activeMode.value === 'write') {
|
||||
if (selection) {
|
||||
options.setTextareaSelection(selection.start, selection.end, { preserveScroll: true })
|
||||
return
|
||||
}
|
||||
|
||||
options.focusTextareaAtLine(focusLine)
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const offset = selection
|
||||
? Math.max(0, selection.start - options.getLineStartOffset(focusLine))
|
||||
: 0
|
||||
|
||||
options.focusPreviewEditableAtLine(focusLine, 0, 'auto', offset, 'center')
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
focusFirstBlock,
|
||||
getInitialContentFocusLineIndex,
|
||||
ensureContentFocusLineExists,
|
||||
restoreEditorFocusAfterHistory
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user