feat: 사이드바 브랜드/헤더 타이틀 + 라이트·다크 테마 설정
1) 사이드바 상단에 돼지 로고 + '돈돼지 가계부'('메뉴' 텍스트 대체, 데스크톱/모바일 공통)
2) 헤더에서 돼지 로고 제거 → 현재 화면 타이틀 표시(라우트별)
3) 설정에 화면 테마(시스템/라이트/다크) 선택 추가
- base.css: data-theme 기반 수동 다크 + 시스템 설정 폴백
- theme.js 유틸 + ui 스토어 theme 상태, main.js 시작 시 적용(깜빡임 방지)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+17
-3
@@ -36,8 +36,8 @@
|
|||||||
--section-gap: 160px;
|
--section-gap: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* 다크 변수 묶음 — 명시적 다크(data-theme=dark)와 시스템 다크에서 공통 사용 */
|
||||||
:root {
|
:root[data-theme='dark'] {
|
||||||
--color-background: var(--vt-c-black);
|
--color-background: var(--vt-c-black);
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
@@ -46,7 +46,21 @@
|
|||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
--color-text: var(--vt-c-text-dark-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 시스템 설정 따름(테마 미지정 = 시스템). 명시적 라이트/다크가 있으면 그게 우선 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme]) {
|
||||||
|
--color-background: var(--vt-c-black);
|
||||||
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
|
--color-text: var(--vt-c-text-dark-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,39 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
import { boardLabel } from '@/constants/boards'
|
||||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 라우트별 헤더 타이틀(현재 화면 이름)
|
||||||
|
const TITLES = {
|
||||||
|
home: '돈돼지 가계부',
|
||||||
|
'account-entries': '가계부 내역',
|
||||||
|
'account-stats': '통계',
|
||||||
|
'account-recurrings': '고정 지출',
|
||||||
|
'account-wallets': '계좌 관리',
|
||||||
|
'account-categories': '분류 관리',
|
||||||
|
'account-budget': '예산 설정',
|
||||||
|
'account-tags': '태그 관리',
|
||||||
|
users: '회원 관리',
|
||||||
|
'admin-tags': '태그 관리(관리자)',
|
||||||
|
'admin-default-categories': '기본 분류 설정',
|
||||||
|
settings: '설정',
|
||||||
|
'settings-account': '계정정보',
|
||||||
|
'settings-account-edit': '가입정보 변경',
|
||||||
|
}
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
const n = route.name
|
||||||
|
if (n === 'board' || n === 'board-detail') return boardLabel(route.params.category)
|
||||||
|
if (n === 'board-write' || n === 'board-edit') return route.params.id ? '글 수정' : '글쓰기'
|
||||||
|
return TITLES[n] || '돈돼지 가계부'
|
||||||
|
})
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
@@ -22,10 +49,7 @@ async function handleLogout() {
|
|||||||
<path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" />
|
<path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<RouterLink to="/" class="brand">
|
<h1 class="page-title">{{ pageTitle }}</h1>
|
||||||
<img class="brand-mark" src="/logo-piggy.png" alt="" aria-hidden="true" />
|
|
||||||
<span class="brand-text">돈돼지 가계부</span>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -66,27 +90,14 @@ async function handleLogout() {
|
|||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
.brand {
|
.page-title {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
|
||||||
.brand:hover {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.brand-mark {
|
|
||||||
height: 28px;
|
|
||||||
width: auto;
|
|
||||||
max-width: 38px;
|
|
||||||
object-fit: contain;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.brand-text {
|
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ const icons = {
|
|||||||
<template>
|
<template>
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
<span class="drawer-title">메뉴</span>
|
<RouterLink to="/" class="brand" @click="ui.closeSidebar()">
|
||||||
|
<img class="brand-mark" src="/logo-piggy.png" alt="" aria-hidden="true" />
|
||||||
|
<span class="brand-text">돈돼지 가계부</span>
|
||||||
|
</RouterLink>
|
||||||
<button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button>
|
<button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="menu">
|
<nav class="menu">
|
||||||
@@ -99,26 +102,38 @@ const icons = {
|
|||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
.drawer-head {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.app-sidebar {
|
|
||||||
background: var(--color-background);
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
.drawer-head {
|
.drawer-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.5rem 1rem 0.75rem;
|
padding: 0.5rem 1.1rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.drawer-title {
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
.brand:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 34px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand-text {
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.drawer-close {
|
.drawer-close {
|
||||||
|
display: none; /* 데스크톱: 숨김 (모바일 드로어에서만 노출) */
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -126,6 +141,14 @@ const icons = {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-sidebar {
|
||||||
|
background: var(--color-background);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.drawer-close {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import router from './router'
|
|||||||
import { setupCapacitor } from './capacitor'
|
import { setupCapacitor } from './capacitor'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useDialog } from './composables/dialog'
|
import { useDialog } from './composables/dialog'
|
||||||
|
import { initTheme } from './theme'
|
||||||
|
|
||||||
|
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
|
||||||
|
initTheme()
|
||||||
|
|
||||||
// 네이티브 alert 를 인앱 다이얼로그로 대체 (PC 'sb_pt' 제목 제거 + 웹/APK 일관).
|
// 네이티브 alert 를 인앱 다이얼로그로 대체 (PC 'sb_pt' 제목 제거 + 웹/APK 일관).
|
||||||
// confirm/prompt 는 동기 반환이라 오버라이드 불가 → 각 호출부에서 await dialog.confirm/prompt 사용.
|
// confirm/prompt 는 동기 반환이라 오버라이드 불가 → 각 호출부에서 await dialog.confirm/prompt 사용.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { authApi } from '@/api/authApi'
|
import { authApi } from '@/api/authApi'
|
||||||
|
import { getStoredTheme, setTheme as persistTheme } from '@/theme'
|
||||||
|
|
||||||
// 전역 UI 상태 (로그인/회원가입 레이어 팝업 등)
|
// 전역 UI 상태 (로그인/회원가입 레이어 팝업 등)
|
||||||
export const useUiStore = defineStore('ui', () => {
|
export const useUiStore = defineStore('ui', () => {
|
||||||
@@ -28,6 +29,13 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
sidebarOpen.value = false
|
sidebarOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 테마 (system | light | dark)
|
||||||
|
const theme = ref(getStoredTheme())
|
||||||
|
function setTheme(t) {
|
||||||
|
theme.value = t
|
||||||
|
persistTheme(t)
|
||||||
|
}
|
||||||
|
|
||||||
// 비밀번호 변경 모달
|
// 비밀번호 변경 모달
|
||||||
const passwordOpen = ref(false)
|
const passwordOpen = ref(false)
|
||||||
function openPassword() {
|
function openPassword() {
|
||||||
@@ -63,5 +71,6 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
signupEnabled, loadSignupEnabled,
|
signupEnabled, loadSignupEnabled,
|
||||||
sidebarOpen, toggleSidebar, closeSidebar,
|
sidebarOpen, toggleSidebar, closeSidebar,
|
||||||
passwordOpen, openPassword, closePassword,
|
passwordOpen, openPassword, closePassword,
|
||||||
|
theme, setTheme,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// 테마(라이트/다크/시스템) 적용 — <html data-theme> 속성으로 제어.
|
||||||
|
// light/dark : 명시 적용, system : 속성 제거 → OS prefers-color-scheme 따름
|
||||||
|
const KEY = 'theme'
|
||||||
|
|
||||||
|
export function getStoredTheme() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) || 'system'
|
||||||
|
} catch {
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme) {
|
||||||
|
const el = document.documentElement
|
||||||
|
if (theme === 'light' || theme === 'dark') {
|
||||||
|
el.setAttribute('data-theme', theme)
|
||||||
|
} else {
|
||||||
|
el.removeAttribute('data-theme') // system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(theme) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, theme)
|
||||||
|
} catch {
|
||||||
|
/* 저장 실패 무시 */
|
||||||
|
}
|
||||||
|
applyTheme(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 앱 시작 시 저장값 즉시 적용 (마운트 전 호출 → 깜빡임 방지)
|
||||||
|
export function initTheme() {
|
||||||
|
applyTheme(getStoredTheme())
|
||||||
|
}
|
||||||
@@ -3,8 +3,15 @@ import { ref } from 'vue'
|
|||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { Preferences } from '@capacitor/preferences'
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import { useDialog } from '@/composables/dialog'
|
import { useDialog } from '@/composables/dialog'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const ui = useUiStore()
|
||||||
|
const THEMES = [
|
||||||
|
{ value: 'system', label: '시스템' },
|
||||||
|
{ value: 'light', label: '라이트' },
|
||||||
|
{ value: 'dark', label: '다크' },
|
||||||
|
]
|
||||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
|
||||||
const clearing = ref(false)
|
const clearing = ref(false)
|
||||||
|
|
||||||
@@ -37,6 +44,25 @@ async function clearAppData() {
|
|||||||
<div class="settings">
|
<div class="settings">
|
||||||
<h1 class="page-title">설정</h1>
|
<h1 class="page-title">설정</h1>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-main">
|
||||||
|
<span class="row-label">화면 테마</span>
|
||||||
|
<span class="row-sub">라이트 / 다크 모드 선택</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-seg">
|
||||||
|
<button
|
||||||
|
v-for="t in THEMES"
|
||||||
|
:key="t.value"
|
||||||
|
type="button"
|
||||||
|
class="seg-btn"
|
||||||
|
:class="{ active: ui.theme === t.value }"
|
||||||
|
@click="ui.setTheme(t.value)"
|
||||||
|
>{{ t.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<RouterLink to="/settings/account" class="row row-link">
|
<RouterLink to="/settings/account" class="row row-link">
|
||||||
<div class="row-main">
|
<div class="row-main">
|
||||||
@@ -132,6 +158,30 @@ async function clearAppData() {
|
|||||||
.danger {
|
.danger {
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
}
|
}
|
||||||
|
.theme-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.seg-btn {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border: 0;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.seg-btn:first-child {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
.seg-btn.active {
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.chevron {
|
.chevron {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|||||||
Reference in New Issue
Block a user