테마 깜빡임·로딩 스플래시 및 메인 커버 저장 흐름 수정
head 인라인 스크립트로 data-theme 선적용, 로고 캐시 스플래시 추가. 메인 커버는 업로드 후 저장 버튼에서 이미지·텍스트 일괄 반영. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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>
|
||||
5
app.vue
5
app.vue
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
|
||||
key: 'site-settings-public',
|
||||
default: () => ({
|
||||
title: 'sori.studio',
|
||||
faviconUrl: ''
|
||||
faviconUrl: '',
|
||||
logoUrl: '',
|
||||
logoText: '井'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const hasOverlay = computed(() => Boolean(props.title?.trim() || props.text?.tri
|
||||
<template>
|
||||
<section
|
||||
v-if="imageUrl"
|
||||
class="home-hero relative mx-auto w-full max-w-[720px] overflow-hidden rounded-[10px]"
|
||||
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)]">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const themeStorageKey = 'SITE_THEME'
|
||||
import {
|
||||
SITE_THEME_STORAGE_KEY,
|
||||
resolveSiteTheme
|
||||
} from '~/lib/site-theme-init.js'
|
||||
|
||||
/**
|
||||
* HTML 루트 요소에 현재 테마를 반영한다.
|
||||
@@ -34,19 +37,23 @@ export const useThemeMode = () => {
|
||||
const theme = useState('site-theme-mode', () => 'light')
|
||||
const isDarkMode = computed(() => theme.value === 'dark')
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem(themeStorageKey)
|
||||
const nextTheme = savedTheme === 'light' || savedTheme === 'dark' ? savedTheme : getSystemTheme()
|
||||
theme.value = nextTheme
|
||||
applyThemeToDocument(nextTheme)
|
||||
})
|
||||
if (import.meta.client) {
|
||||
const fromDocument = document.documentElement.dataset.theme
|
||||
if (fromDocument === 'light' || fromDocument === 'dark') {
|
||||
theme.value = fromDocument
|
||||
} else {
|
||||
const savedTheme = localStorage.getItem(SITE_THEME_STORAGE_KEY)
|
||||
theme.value = resolveSiteTheme(savedTheme, getSystemTheme() === 'dark')
|
||||
applyThemeToDocument(theme.value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(theme, (nextTheme) => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(themeStorageKey, nextTheme)
|
||||
localStorage.setItem(SITE_THEME_STORAGE_KEY, nextTheme)
|
||||
applyThemeToDocument(nextTheme)
|
||||
})
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
|
||||
- 라이트 모드 기본 배경은 `#fcfcfc`로 통일하고 패널 구분은 보더로 처리
|
||||
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리한다. 첫 페인트 전 `lib/site-theme-init.js` 인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는 `#site-splash`에 캐시된 로고(`SITE_BRAND_LOGO_URL`) 또는 로고 텍스트를 잠깐 표시하고, 앱 마운트 후 `site-app-ready`로 숨긴다.
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||
|
||||
### 홈 Featured (인덱스)
|
||||
@@ -436,7 +436,7 @@ components/content/
|
||||
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt` 포함)
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, `homeCoverImageUrl`, `homeCoverTitle`, `homeCoverText` 포함)
|
||||
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 업로드(720px WebP)
|
||||
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
|
||||
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 홈 커버 필드 포함)
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
@@ -564,7 +564,7 @@ components/content/
|
||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존·어나운스 바·게시물 Import/Export·스팸 필터는 현재 **메뉴·안내 카드만** 제공하고 저장 API는 연결하지 않는다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다.
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||
- **메인 화면**(`home_cover_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 커버 이미지는 `/admin/api/settings/home-cover`로 업로드(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`). 텍스트·이미지 제거는 `PUT /admin/api/settings`로 저장한다.
|
||||
- **메인 화면**(`home_cover_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 관리자 UI에서는 커버 파일 업로드·제목·본문을 편집한 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
|
||||
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록·공개 글 상세에 수정 시각 보조 줄을 표시할지 여부.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp`와 `/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
## v1.2.9
|
||||
|
||||
- 홈 상단: Ghost형 헤딩·구독 폼 제거. 사이트 설정「메인 화면」에서 커버 이미지(720px)·오버레이 제목·본문 설정. `HomeHero.vue`, 마이그레이션 `027_site_settings_home_cover.sql`.
|
||||
- 메인 화면 설정: 커버 업로드 시 제목·본문이 리셋되지 않도록 업로드는 파일만·저장 버튼에서 이미지·텍스트 일괄 `PUT` 반영.
|
||||
- 테마 깜빡임: head 인라인 스크립트로 `data-theme` 선적용. 로딩 스플래시(`app.html`·캐시된 사이트 로고).
|
||||
- 홈 Latest 피드: List(썸네일+본문)·Compact(텍스트만)·Cards(2열) 보기 구분. 메뉴 List/Compact 선택값과 레이아웃 일치. Default 클릭 시 Compact로 전환. Cards 상단 여백·테두리 클리핑 수정.
|
||||
- 게시물 카드: 대표 이미지 없을 때 썸네일 영역에 제목 텍스트 플레이스홀더(`PostCardMedia`). 홈 Latest·태그·게시물 목록 공통.
|
||||
- 슬래시 메뉴: 키보드 ↓ 이동 시 scrollIntoView+mouseenter 충돌로 하단 항목이 반복 선택되던 문제 수정.
|
||||
|
||||
34
lib/site-theme-init.js
Normal file
34
lib/site-theme-init.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/** @type {'light' | 'dark'} */
|
||||
export const SITE_THEME_LIGHT = 'light'
|
||||
|
||||
/** @type {'light' | 'dark'} */
|
||||
export const SITE_THEME_DARK = 'dark'
|
||||
|
||||
/** localStorage 키: 사용자가 선택한 사이트 테마 */
|
||||
export const SITE_THEME_STORAGE_KEY = 'SITE_THEME'
|
||||
|
||||
/** localStorage 키: 스플래시용 로고 이미지 URL(이전 방문에서 캐시) */
|
||||
export const SITE_BRAND_LOGO_URL_KEY = 'SITE_BRAND_LOGO_URL'
|
||||
|
||||
/** localStorage 키: 스플래시용 로고 텍스트 fallback */
|
||||
export const SITE_BRAND_LOGO_TEXT_KEY = 'SITE_BRAND_LOGO_TEXT'
|
||||
|
||||
/**
|
||||
* 저장값·시스템 설정으로 적용할 테마를 결정한다.
|
||||
* @param {string|null|undefined} savedTheme - localStorage 값
|
||||
* @param {boolean} prefersDark - 시스템 다크 모드 여부
|
||||
* @returns {'light' | 'dark'}
|
||||
*/
|
||||
export const resolveSiteTheme = (savedTheme, prefersDark) => {
|
||||
if (savedTheme === SITE_THEME_LIGHT || savedTheme === SITE_THEME_DARK) {
|
||||
return savedTheme
|
||||
}
|
||||
|
||||
return prefersDark ? SITE_THEME_DARK : SITE_THEME_LIGHT
|
||||
}
|
||||
|
||||
/**
|
||||
* 첫 페인트 전에 테마·스플래시를 준비하는 head 인라인 스크립트 본문
|
||||
* @returns {string}
|
||||
*/
|
||||
export const buildSiteBootInlineScript = () => `(function(){try{var sk=${JSON.stringify(SITE_THEME_STORAGE_KEY)};var lk=${JSON.stringify(SITE_BRAND_LOGO_URL_KEY)};var tk=${JSON.stringify(SITE_BRAND_LOGO_TEXT_KEY)};var root=document.documentElement;var prefersDark=window.matchMedia("(prefers-color-scheme: dark)").matches;var saved=localStorage.getItem(sk);var theme=(saved==="light"||saved==="dark")?saved:(prefersDark?"dark":"light");root.dataset.theme=theme;root.style.colorScheme=theme;if(/^\\/(admin|signin|signup|forgot-password)(\\/|$)/.test(location.pathname)){root.classList.add("site-app-ready");return}var splash=document.getElementById("site-splash");if(!splash){return}var logoEl=document.getElementById("site-splash-logo");var textEl=document.getElementById("site-splash-text");var logoUrl=localStorage.getItem(lk)||"";var logoText=localStorage.getItem(tk)||"sori.studio";if(logoUrl&&logoEl){logoEl.src=logoUrl;logoEl.hidden=false;if(textEl){textEl.hidden=true}}else if(textEl){textEl.textContent=logoText;textEl.hidden=false}}catch(e){}})();`
|
||||
@@ -1,4 +1,6 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
import { buildSiteBootInlineScript } from './lib/site-theme-init.js'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2026-04-29',
|
||||
future: {
|
||||
@@ -42,6 +44,14 @@ export default defineNuxtConfig({
|
||||
meta: [
|
||||
{ name: 'description', content: 'sori.studio 개인 블로그' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
|
||||
],
|
||||
script: [
|
||||
{
|
||||
key: 'site-boot',
|
||||
type: 'text/javascript',
|
||||
tagPriority: 'critical',
|
||||
innerHTML: buildSiteBootInlineScript()
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -526,13 +526,12 @@ const uploadHomeCover = async (event) => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const updatedSettings = await $fetch('/admin/api/settings/home-cover', {
|
||||
const { homeCoverImageUrl } = await $fetch('/admin/api/settings/home-cover', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
Object.assign(form, updatedSettings)
|
||||
homeCoverSnapshot.homeCoverImageUrl = form.homeCoverImageUrl
|
||||
showToast('success', '커버 이미지가 등록되었습니다.')
|
||||
form.homeCoverImageUrl = homeCoverImageUrl || ''
|
||||
showToast('success', '커버 이미지를 불러왔습니다. 저장 버튼을 눌러 적용하세요.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '커버 이미지 업로드에 실패했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
@@ -1125,7 +1124,7 @@ onBeforeUnmount(() => {
|
||||
v-if="!editHomeCover"
|
||||
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
|
||||
>
|
||||
홈 상단에 720px 너비 커버 이미지를 표시합니다. 제목·짧은 문구는 이미지 왼쪽 하단에 겹쳐 보입니다.
|
||||
홈 상단에 720px 너비 커버 이미지를 표시합니다. 제목·짧은 문구는 이미지 왼쪽 하단에 겹쳐 보이며, 이미지·텍스트는 저장 버튼으로 함께 반영합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
|
||||
@@ -1190,7 +1189,7 @@ onBeforeUnmount(() => {
|
||||
커버 이미지
|
||||
</h3>
|
||||
<p class="mt-1 max-w-md text-sm leading-relaxed text-[#657080]">
|
||||
가로 720px 기준으로 저장됩니다. JPG·PNG·WebP를 사용할 수 있습니다.
|
||||
가로 720px WebP로 변환해 미리 불러옵니다. 제목·본문과 함께 저장 버튼을 눌러야 사이트에 반영됩니다.
|
||||
</p>
|
||||
<div
|
||||
v-if="form.homeCoverImageUrl"
|
||||
|
||||
@@ -282,7 +282,7 @@ const scrollFeatured = (direction) => {
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section v-if="siteSettings?.homeCoverImageUrl" class="home-page__hero px-6 pb-2 pt-6 md:pt-8">
|
||||
<section v-if="siteSettings?.homeCoverImageUrl" class="home-page__hero">
|
||||
<HomeHero
|
||||
:image-url="siteSettings.homeCoverImageUrl"
|
||||
:title="siteSettings.homeCoverTitle"
|
||||
|
||||
68
plugins/site-app-ready.client.js
Normal file
68
plugins/site-app-ready.client.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
SITE_BRAND_LOGO_TEXT_KEY,
|
||||
SITE_BRAND_LOGO_URL_KEY,
|
||||
SITE_THEME_STORAGE_KEY,
|
||||
resolveSiteTheme
|
||||
} from '../lib/site-theme-init.js'
|
||||
|
||||
const siteSettingsFetchKey = 'site-settings-public'
|
||||
|
||||
/**
|
||||
* 공개 사이트 설정에서 스플래시용 브랜드를 localStorage에 캐시한다.
|
||||
* @param {Object|null|undefined} settings - 사이트 설정
|
||||
* @returns {void}
|
||||
*/
|
||||
const cacheSiteBrandForSplash = (settings) => {
|
||||
if (!settings?.logoUrl && !settings?.logoText) {
|
||||
return
|
||||
}
|
||||
|
||||
if (settings.logoUrl) {
|
||||
localStorage.setItem(SITE_BRAND_LOGO_URL_KEY, settings.logoUrl)
|
||||
}
|
||||
|
||||
if (settings.logoText) {
|
||||
localStorage.setItem(SITE_BRAND_LOGO_TEXT_KEY, settings.logoText)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스플래시를 숨기고 본문을 표시한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const finishSiteSplash = () => {
|
||||
document.documentElement.classList.add('site-app-ready')
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증·관리자 경로는 스플래시 없이 즉시 본문을 연다.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const shouldSkipSiteSplash = () => /^\/(admin|signin|signup|forgot-password)(\/|$)/.test(window.location.pathname)
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const theme = useState('site-theme-mode', () => 'light')
|
||||
|
||||
const savedTheme = localStorage.getItem(SITE_THEME_STORAGE_KEY)
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
theme.value = resolveSiteTheme(savedTheme, prefersDark)
|
||||
|
||||
if (shouldSkipSiteSplash()) {
|
||||
finishSiteSplash()
|
||||
return
|
||||
}
|
||||
|
||||
const { data: siteSettings } = useNuxtData(siteSettingsFetchKey)
|
||||
|
||||
watch(siteSettings, (settings) => {
|
||||
cacheSiteBrandForSplash(settings)
|
||||
}, { immediate: true })
|
||||
|
||||
nuxtApp.hook('app:mounted', () => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
finishSiteSplash()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import { join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { updateSiteHomeCoverImage } from '../../../../repositories/content-repository'
|
||||
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
|
||||
|
||||
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
@@ -86,5 +85,5 @@ export default defineEventHandler(async (event) => {
|
||||
await writeFile(coverPath, coverBuffer)
|
||||
await upsertMediaMetadataCategory(homeCoverImageUrl, '시스템')
|
||||
|
||||
return updateSiteHomeCoverImage({ homeCoverImageUrl })
|
||||
return { homeCoverImageUrl }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user