테마 깜빡임·로딩 스플래시 및 메인 커버 저장 흐름 수정

head 인라인 스크립트로 data-theme 선적용, 로고 캐시 스플래시 추가.
메인 커버는 업로드 후 저장 버튼에서 이미지·텍스트 일괄 반영.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-18 18:44:17 +09:00
parent 3fb8a40031
commit 797a6dd5a0
13 changed files with 200 additions and 22 deletions

26
app.html Normal file
View 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>

View File

@@ -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: '井'
})
})

View File

@@ -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;
}

View File

@@ -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)]">

View File

@@ -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)
})

View File

@@ -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 값을 사용한다.

View File

@@ -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
View 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){}})();`

View File

@@ -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()
}
]
}
},

View File

@@ -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"

View File

@@ -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"

View 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()
})
})
})
})

View File

@@ -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 }
})