사이트 설정 로고와 사용자 설정 레이아웃 정리

This commit is contained in:
2026-05-13 15:42:03 +09:00
parent bebf7ee1c9
commit 52f22b4ff1
17 changed files with 372 additions and 395 deletions

24
app.vue
View File

@@ -1,3 +1,27 @@
<script setup>
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio',
faviconUrl: ''
})
})
useHead(() => ({
titleTemplate: (titleChunk) => titleChunk
? `${titleChunk} · ${appSiteSettings.value.title}`
: appSiteSettings.value.title,
link: appSiteSettings.value.faviconUrl
? [
{
rel: 'icon',
type: 'image/png',
href: appSiteSettings.value.faviconUrl
}
]
: []
}))
</script>
<template>
<NuxtLayout>
<NuxtPage />

View File

@@ -13,6 +13,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
title: 'sori.studio',
description: 'sori.studio 개인 블로그',
logoText: '井',
logoUrl: '',
copyrightText: '©2026 sori.studio'
})
})
@@ -23,8 +24,14 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0">
<div class="right-sidebar__profile flex items-center gap-3">
<div class="right-sidebar__logo grid h-12 w-12 place-items-center rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
{{ siteSettings.logoText }}
<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)]">
<img
v-if="siteSettings.logoUrl"
class="h-full w-full object-cover"
:src="siteSettings.logoUrl"
:alt="siteSettings.title"
>
<span v-else>{{ siteSettings.logoText }}</span>
</div>
<div>
<p class="right-sidebar__title font-semibold">

View File

@@ -8,7 +8,8 @@ const member = ref(null)
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio'
title: 'sori.studio',
logoUrl: ''
})
})
@@ -185,6 +186,12 @@ onBeforeUnmount(() => {
</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>

View File

@@ -0,0 +1,5 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS logo_url TEXT NOT NULL DEFAULT '';
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS favicon_url TEXT NOT NULL DEFAULT '';

View File

@@ -184,6 +184,7 @@ docker run -d -p 3000:3000 sori.studio:latest
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
### 개발/운영 DB 분리 검증 절차

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-13 v0.0.115
### 사이트 설정과 계정 설정 책임 분리
관리자 사이트 설정에 관리자 프로필과 비밀번호 변경을 함께 두면 사이트 메타데이터와 개인 계정 관리가 섞인다. 계정 정보는 멤버 편집 화면에서 처리하고, 사이트 설정은 이름·설명·URL·로고·저작권처럼 공개 사이트 자체에 영향을 주는 값만 남긴다. 공개 사용자 설정은 중앙 컬럼 폭이 좁아 3분할 구조가 답답하므로 요약을 상단에 둔 세로형 흐름으로 바꾸고, 로고는 텍스트 대신 1:1 이미지를 저장해 공개 로고와 파비콘에 함께 사용한다.
## 2026-05-13 v0.0.114
### 멤버 계정 작업과 사용자 설정 화면 정리

View File

@@ -10,6 +10,7 @@
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
| layouts/page.vue | 고정 페이지 전체 화면 |
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template |
## Composables
@@ -41,11 +42,11 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
@@ -109,7 +110,7 @@
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·파비콘 생성, 저작권 문구) |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
@@ -129,7 +130,7 @@
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
| pages/settings/index.vue | 회원 설정(Ghost형 프로필 요약, 가입 정보, 댓글 참여도, 이전 로그인 활동, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
| pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
## 서버 API

View File

@@ -290,7 +290,9 @@ components/content/
| title | String | 사이트 이름 |
| description | String | 사이트 설명 |
| site_url | String | 사이트 기본 URL |
| logo_text | String | 텍스트 로고 |
| logo_text | String | 레거시 텍스트 로고 fallback |
| logo_url | String | 공개 로고 이미지 URL |
| favicon_url | String | 파비콘 이미지 URL |
| copyright_text | String | 저작권 문구 |
| updated_at | DateTime | 수정일 |
@@ -527,8 +529,10 @@ components/content/
### 사이트 설정
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 텍스트 로고, 저작권 문구를 수정할 수 있다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo.webp``/uploads/system/favicon.png`를 함께 생성한다.
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
### 메뉴/네비게이션
@@ -552,7 +556,7 @@ components/content/
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필``/admin/members/:id` 멤버 편집 화면으로 이동한다.
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
@@ -572,7 +576,7 @@ components/content/
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `GET/PUT /api/auth/profile`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
- 회원 로그인 성공 시 `previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다.
- 사용자 설정 화면은 프로필, 가입 정보, 댓글 참여도, 활동 정보를 기본으로 표시한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다.
- 사용자 설정 화면은 공개 본문 폭에 맞춰 프로필 요약을 상단에 두고, 프로필 입력과 활동 정보를 하단에 배치한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다.
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.

View File

@@ -1,5 +1,15 @@
# 업데이트 이력
## v0.0.115
- 사용자 설정 화면을 좁은 공개 본문 폭에 맞춰 요약 영역 상단, 프로필·활동 영역 하단 구조로 재배치.
- 사용자 설정 비밀번호 변경·회원 탈퇴 모달 입력 필드 보더가 보이도록 정리.
- 관리자 사이트 설정에서 관리자 프로필·관리자 비밀번호 변경 섹션 제거.
- 관리자 사이트 설정에 1:1 로고 이미지 업로드와 파비콘 생성 기능 추가.
- 사이트 설정 로고 URL·파비콘 URL 저장 컬럼 마이그레이션(`022_add_site_logo_urls.sql`) 추가.
- 공개 헤더와 오른쪽 사이드바에 이미지 로고 표시를 연결하고 파비콘 head 링크를 추가.
- 패키지 버전 `0.0.115`로 갱신.
## v0.0.114
- 관리자 하단 사용자 메뉴의 `내 프로필` 경로를 사용자 설정에서 관리자 멤버 편집 화면으로 변경.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "0.0.114",
"version": "0.0.115",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "0.0.114",
"version": "0.0.115",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.114",
"version": "0.0.115",
"private": true,
"type": "module",
"imports": {

View File

@@ -4,17 +4,11 @@ definePageMeta({
})
const saving = ref(false)
const loadingProfile = ref(true)
const savingProfile = ref(false)
const savingPassword = ref(false)
const uploadingAvatar = ref(false)
const removingAvatar = ref(false)
const uploadingLogo = ref(false)
const errorMessage = ref('')
const profileMessage = ref('')
const passwordMessage = ref('')
const toast = ref(null)
const logoInputRef = ref(null)
let toastTimer = null
const avatarInputRef = ref(null)
const { data: settings } = await useFetch('/admin/api/settings')
@@ -23,207 +17,11 @@ const form = reactive({
description: settings.value?.description || '',
siteUrl: settings.value?.siteUrl || 'https://sori.studio',
logoText: settings.value?.logoText || '井',
logoUrl: settings.value?.logoUrl || '',
faviconUrl: settings.value?.faviconUrl || '',
copyrightText: settings.value?.copyrightText || '©2026 sori.studio'
})
const profileForm = reactive({
email: '',
username: '',
avatarUrl: ''
})
const passwordForm = reactive({
currentPassword: '',
nextPassword: '',
nextPasswordConfirm: ''
})
/**
* 관리자 프로필을 조회한다.
* @returns {Promise<void>}
*/
const loadAdminProfile = async () => {
loadingProfile.value = true
profileMessage.value = ''
try {
const profile = await $fetch('/api/auth/profile')
profileForm.email = profile.email || ''
profileForm.username = profile.username || ''
profileForm.avatarUrl = profile.avatarUrl || ''
} catch {
profileMessage.value = '관리자 프로필을 불러오지 못했습니다. 다시 로그인해 주세요.'
} finally {
loadingProfile.value = false
}
}
/**
* 닉네임 중복 여부를 확인한다.
* @returns {Promise<boolean>} 사용 가능 여부
*/
const checkUsernameAvailable = async () => {
const username = profileForm.username.trim()
if (!username) {
profileMessage.value = '관리자 이름을 입력해 주세요.'
return false
}
try {
const result = await $fetch('/api/auth/check-username', {
query: {
username
}
})
if (!result.available) {
profileMessage.value = '이미 사용 중인 이름입니다.'
return false
}
} catch (error) {
profileMessage.value = error?.data?.message || '이름 중복 확인에 실패했습니다.'
return false
}
return true
}
/**
* 관리자 프로필을 저장한다.
* @returns {Promise<void>}
*/
const saveAdminProfile = async () => {
const available = await checkUsernameAvailable()
if (!available) {
return
}
savingProfile.value = true
profileMessage.value = ''
try {
await $fetch('/api/auth/profile', {
method: 'PUT',
body: {
username: profileForm.username.trim(),
avatarUrl: profileForm.avatarUrl.trim()
}
})
profileMessage.value = '관리자 프로필이 저장되었습니다.'
} catch (error) {
profileMessage.value = error?.data?.message || '관리자 프로필 저장에 실패했습니다.'
} finally {
savingProfile.value = false
}
}
/**
* 관리자 썸네일 파일 선택창을 연다.
* @returns {void}
*/
const openAvatarFilePicker = () => {
avatarInputRef.value?.click()
}
/**
* 관리자 썸네일을 업로드한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {Promise<void>}
*/
const uploadAvatar = async (event) => {
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
const file = target?.files?.[0]
if (!file || uploadingAvatar.value) {
return
}
uploadingAvatar.value = true
profileMessage.value = ''
try {
const formData = new FormData()
formData.append('file', file)
const result = await $fetch('/api/auth/avatar', {
method: 'POST',
body: formData
})
profileForm.avatarUrl = result.avatarUrl || ''
profileMessage.value = '관리자 썸네일이 업로드되었습니다.'
} catch (error) {
profileMessage.value = error?.data?.message || '관리자 썸네일 업로드에 실패했습니다.'
} finally {
uploadingAvatar.value = false
if (target) {
target.value = ''
}
}
}
/**
* 관리자 썸네일을 제거한다.
* @returns {Promise<void>}
*/
const removeAvatar = async () => {
if (removingAvatar.value) {
return
}
removingAvatar.value = true
profileMessage.value = ''
try {
await $fetch('/api/auth/avatar', {
method: 'DELETE'
})
profileForm.avatarUrl = ''
profileMessage.value = '관리자 썸네일이 제거되었습니다.'
} catch (error) {
profileMessage.value = error?.data?.message || '관리자 썸네일 제거에 실패했습니다.'
} finally {
removingAvatar.value = false
}
}
/**
* 관리자 비밀번호를 변경한다.
* @returns {Promise<void>}
*/
const saveAdminPassword = async () => {
passwordMessage.value = ''
if (!passwordForm.currentPassword || !passwordForm.nextPassword || !passwordForm.nextPasswordConfirm) {
passwordMessage.value = '모든 비밀번호 입력값을 작성해 주세요.'
return
}
if (passwordForm.nextPassword !== passwordForm.nextPasswordConfirm) {
passwordMessage.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
return
}
savingPassword.value = true
try {
await $fetch('/api/auth/password', {
method: 'PUT',
body: {
currentPassword: passwordForm.currentPassword,
nextPassword: passwordForm.nextPassword
}
})
passwordForm.currentPassword = ''
passwordForm.nextPassword = ''
passwordForm.nextPasswordConfirm = ''
passwordMessage.value = '관리자 비밀번호가 변경되었습니다.'
} catch (error) {
passwordMessage.value = error?.data?.message || '관리자 비밀번호 변경에 실패했습니다.'
} finally {
savingPassword.value = false
}
}
/**
* 저장 상태 토스트 표시
* @param {'success'|'error'|'info'} type - 토스트 타입
@@ -238,6 +36,51 @@ const showToast = (type, message) => {
}, 3200)
}
/**
* 로고 파일 선택창을 연다.
* @returns {void}
*/
const openLogoFilePicker = () => {
logoInputRef.value?.click()
}
/**
* 사이트 로고를 업로드한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {Promise<void>}
*/
const uploadLogo = async (event) => {
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
const file = target?.files?.[0]
if (!file || uploadingLogo.value) {
return
}
uploadingLogo.value = true
errorMessage.value = ''
showToast('info', '로고를 업로드하는 중입니다.')
try {
const formData = new FormData()
formData.append('file', file)
const updatedSettings = await $fetch('/admin/api/settings/logo', {
method: 'POST',
body: formData
})
Object.assign(form, updatedSettings)
showToast('success', '로고가 등록되었습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || '로고 업로드에 실패했습니다.'
showToast('error', errorMessage.value)
} finally {
uploadingLogo.value = false
if (target) {
target.value = ''
}
}
}
/**
* 사이트 설정 저장
* @returns {Promise<void>} 저장 결과
@@ -254,7 +97,9 @@ const saveSettings = async () => {
title: form.title,
description: form.description,
siteUrl: form.siteUrl,
logoText: form.logoText,
logoText: form.logoText || '井',
logoUrl: form.logoUrl,
faviconUrl: form.faviconUrl,
copyrightText: form.copyrightText
}
})
@@ -272,8 +117,6 @@ const saveSettings = async () => {
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
})
onMounted(loadAdminProfile)
</script>
<template>
@@ -291,140 +134,47 @@ onMounted(loadAdminProfile)
{{ errorMessage }}
</p>
<section class="admin-settings__profile mb-6 max-w-3xl rounded border border-line bg-white p-5">
<h2 class="text-base font-semibold text-ink">관리자 프로필</h2>
<p class="mt-1 text-xs text-muted">
썸네일과 이름을 수정할 있습니다.
</p>
<div v-if="loadingProfile" class="mt-4 text-sm text-muted">
관리자 프로필을 불러오는 중입니다.
</div>
<div v-else class="mt-4 flex flex-col gap-4">
<div class="flex flex-col gap-3 rounded border border-line bg-paper p-3 md:flex-row md:items-center">
<div class="relative w-fit shrink-0">
<button
type="button"
class="group relative h-24 w-24 overflow-hidden rounded-full border border-line bg-white"
:disabled="uploadingAvatar || removingAvatar"
@click="openAvatarFilePicker"
>
<form class="admin-settings__form grid max-w-4xl gap-6" @submit.prevent="saveSettings">
<section class="admin-settings__logo rounded-xl border border-line bg-white p-5">
<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="admin-settings__logo-preview grid h-20 w-20 shrink-0 place-items-center overflow-hidden rounded-2xl border border-line bg-paper">
<img
v-if="profileForm.avatarUrl"
:src="profileForm.avatarUrl"
alt="관리자 썸네일"
v-if="form.logoUrl"
class="h-full w-full object-cover"
:src="form.logoUrl"
alt="사이트 로고"
>
<span
v-else
class="grid h-full w-full place-items-center text-2xl font-semibold text-muted"
>
{{ (profileForm.username || profileForm.email || '@').slice(0, 1).toUpperCase() }}
<span v-else class="text-2xl font-semibold text-muted">
{{ form.logoText || '井' }}
</span>
<span class="pointer-events-none absolute inset-0 grid place-items-center bg-black/45 text-[11px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{{ profileForm.avatarUrl ? '이미지 변경' : '썸네일 등록' }}
</span>
</button>
<button
v-if="profileForm.avatarUrl"
type="button"
class="absolute right-0 top-0 grid h-6 w-6 -translate-y-1/3 translate-x-1/3 place-items-center rounded-full border border-line bg-paper text-xs text-muted transition-opacity hover:opacity-80 disabled:opacity-50"
:disabled="removingAvatar"
@click.stop="removeAvatar"
>
{{ removingAvatar ? '...' : 'X' }}
</button>
</div>
<div>
<h2 class="text-base font-semibold text-ink">로고</h2>
<p class="mt-1 max-w-md text-sm leading-6 text-muted">
1:1 비율 이미지로 등록합니다. 같은 이미지가 공개 로고와 파비콘으로 함께 사용됩니다.
</p>
</div>
</div>
<div class="grid min-w-0 flex-1 gap-3">
<label class="grid gap-1 text-sm">
<span class="text-xs text-muted">관리자 이름</span>
<input
v-model="profileForm.username"
type="text"
class="rounded border border-line bg-white px-3 py-2"
>
</label>
<label class="grid gap-1 text-sm">
<span class="text-xs text-muted">관리자 이메일</span>
<input
:value="profileForm.email"
type="text"
class="rounded border border-line bg-[#f7f7f5] px-3 py-2 text-muted"
readonly
>
</label>
</div>
</div>
<input
ref="avatarInputRef"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="hidden"
:disabled="uploadingAvatar"
@change="uploadAvatar"
>
<div class="flex items-center gap-2">
<button
class="rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
class="admin-settings__logo-button h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="savingProfile"
@click="saveAdminProfile"
:disabled="uploadingLogo"
@click="openLogoFilePicker"
>
{{ savingProfile ? '저장 중' : '관리자 프로필 저장' }}
{{ uploadingLogo ? '업로드 중' : form.logoUrl ? '로고 변경' : '로고 등록' }}
</button>
<p v-if="profileMessage" class="text-xs text-muted">
{{ profileMessage }}
</p>
<input
ref="logoInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingLogo"
@change="uploadLogo"
>
</div>
</div>
</section>
</section>
<section class="admin-settings__password mb-6 max-w-3xl rounded border border-line bg-white p-5">
<h2 class="text-base font-semibold text-ink">관리자 비밀번호 변경</h2>
<p class="mt-1 text-xs text-muted">
현재 비밀번호 확인 비밀번호로 변경할 있습니다.
</p>
<div class="mt-4 grid gap-3">
<input
v-model="passwordForm.currentPassword"
type="password"
class="rounded border border-line bg-white px-3 py-2 text-sm"
placeholder="현재 비밀번호"
>
<input
v-model="passwordForm.nextPassword"
type="password"
class="rounded border border-line bg-white px-3 py-2 text-sm"
placeholder="새 비밀번호"
>
<input
v-model="passwordForm.nextPasswordConfirm"
type="password"
class="rounded border border-line bg-white px-3 py-2 text-sm"
placeholder="새 비밀번호 확인"
>
</div>
<div class="mt-4 flex items-center gap-2">
<button
class="rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="button"
:disabled="savingPassword"
@click="saveAdminPassword"
>
{{ savingPassword ? '변경 중' : '관리자 비밀번호 변경' }}
</button>
<p v-if="passwordMessage" class="text-xs text-muted">
{{ passwordMessage }}
</p>
</div>
</section>
<form class="admin-settings__form grid max-w-3xl gap-6" @submit.prevent="saveSettings">
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">사이트 이름</span>
<input
@@ -440,6 +190,7 @@ onMounted(loadAdminProfile)
<textarea
v-model="form.description"
class="admin-settings__textarea min-h-28 resize-y rounded border border-line bg-white px-3 py-2"
required
/>
</label>
@@ -453,36 +204,29 @@ onMounted(loadAdminProfile)
>
</label>
<div class="admin-settings__grid grid gap-4 md:grid-cols-2">
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">로고 텍스트</span>
<input
v-model="form.logoText"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="text"
maxlength="8"
required
>
</label>
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">저작권 문구</span>
<input
v-model="form.copyrightText"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">저작권 문구</span>
<input
v-model="form.copyrightText"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
</div>
<div class="admin-settings__preview rounded border border-line bg-white p-5">
<div class="admin-settings__preview rounded-xl border border-line bg-white p-5">
<p class="admin-settings__preview-label text-xs font-semibold uppercase text-muted">
공개 화면 미리보기
</p>
<div class="admin-settings__preview-body mt-4 flex items-center gap-3">
<div class="admin-settings__preview-logo grid h-12 w-12 place-items-center rounded-2xl bg-[#15171a] text-2xl font-bold text-white">
{{ form.logoText || '井' }}
<div class="admin-settings__preview-logo grid h-12 w-12 place-items-center overflow-hidden rounded-xl bg-[#15171a] text-2xl 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>
<p class="admin-settings__preview-title font-semibold">

View File

@@ -412,8 +412,8 @@ onMounted(loadProfile)
설정 정보를 불러오는 중입니다.
</div>
<div v-else class="settings-page__body mt-10 grid gap-8 lg:grid-cols-3">
<aside class="settings-page__summary">
<div v-else class="settings-page__body mt-10 grid gap-8">
<aside class="settings-page__summary rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] p-5 sm:p-6">
<div class="settings-page__identity flex items-center gap-4">
<div class="settings-page__avatar-control group relative h-24 w-24 shrink-0">
<button class="settings-page__avatar-button relative h-24 w-24 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-panel)]" type="button" :disabled="uploadingAvatar || removingAvatar" :aria-label="profileForm.avatarUrl ? '썸네일 변경' : '썸네일 등록'" @click="openAvatarFilePicker">
@@ -438,31 +438,33 @@ onMounted(loadProfile)
</div>
</div>
<section class="settings-page__side-section mt-12 border-t border-[var(--site-line)] pt-6">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">가입 정보</h3>
<p class="mt-5 text-sm site-muted">
생성됨 <strong class="text-[var(--site-text)]">{{ formatDate(profileForm.createdAt) }}</strong>
</p>
</section>
<div class="settings-page__summary-grid mt-6 grid gap-4 border-t border-[var(--site-line)] pt-5 sm:grid-cols-2">
<section class="settings-page__side-section">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">가입 정보</h3>
<p class="mt-3 text-sm site-muted">
생성됨 <strong class="text-[var(--site-text)]">{{ formatDate(profileForm.createdAt) }}</strong>
</p>
</section>
<section class="settings-page__side-section mt-12 border-t border-[var(--site-line)] pt-6">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">참여도</h3>
<p class="mt-5 text-sm site-muted">
댓글 작성 {{ profileForm.commentCount }}
</p>
</section>
<section class="settings-page__side-section">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">참여도</h3>
<p class="mt-3 text-sm site-muted">
댓글 작성 {{ profileForm.commentCount }}
</p>
</section>
</div>
</aside>
<div class="settings-page__content space-y-8 lg:col-span-2">
<div class="settings-page__content space-y-8">
<form class="settings-page__profile rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] p-5 sm:p-6" @submit.prevent="saveProfile">
<div class="grid gap-5 md:grid-cols-2">
<label class="settings-page__field grid gap-2 text-sm font-semibold">
닉네임
<input v-model="profileForm.username" class="settings-page__input h-12 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-[var(--site-line)] focus:bg-[var(--site-bg)]" type="text" maxlength="60">
<input v-model="profileForm.username" class="settings-page__input h-12 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:bg-[var(--site-bg)]" type="text" maxlength="60">
</label>
<label class="settings-page__field grid gap-2 text-sm font-semibold">
이메일
<input class="settings-page__input h-12 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none" type="email" :value="profileForm.email" readonly>
<input class="settings-page__input h-12 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none" type="email" :value="profileForm.email" readonly>
</label>
</div>
<div class="mt-5 flex items-center justify-between gap-3 border-t border-[var(--site-line)] pt-5">
@@ -520,15 +522,15 @@ onMounted(loadProfile)
<div class="grid gap-4 px-6 py-5">
<label class="grid gap-2 text-sm font-semibold">
현재 비밀번호
<input v-model="passwordForm.currentPassword" class="h-11 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-[var(--site-line)] focus:bg-[var(--site-bg)]" type="password" autocomplete="current-password">
<input v-model="passwordForm.currentPassword" class="h-11 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:bg-[var(--site-bg)]" type="password" autocomplete="current-password">
</label>
<label class="grid gap-2 text-sm font-semibold">
비밀번호
<input v-model="passwordForm.nextPassword" class="h-11 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-[var(--site-line)] focus:bg-[var(--site-bg)]" type="password" autocomplete="new-password">
<input v-model="passwordForm.nextPassword" class="h-11 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:bg-[var(--site-bg)]" type="password" autocomplete="new-password">
</label>
<label class="grid gap-2 text-sm font-semibold">
비밀번호 확인
<input v-model="passwordForm.nextPasswordConfirm" class="h-11 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-[var(--site-line)] focus:bg-[var(--site-bg)]" type="password" autocomplete="new-password">
<input v-model="passwordForm.nextPasswordConfirm" class="h-11 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:bg-[var(--site-bg)]" type="password" autocomplete="new-password">
</label>
<p v-if="passwordMessage" class="rounded-[8px] border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">{{ passwordMessage }}</p>
</div>
@@ -559,7 +561,7 @@ onMounted(loadProfile)
<p class="text-sm leading-6 site-muted">
탈퇴하면 계정과 작성한 댓글이 삭제됩니다. 계속하려면 비밀번호를 입력해 주세요.
</p>
<input v-model="deleteForm.password" class="h-11 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-red-400 focus:bg-[var(--site-bg)]" type="password" autocomplete="current-password" placeholder="비밀번호 확인">
<input v-model="deleteForm.password" class="h-11 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-red-400 focus:bg-[var(--site-bg)]" type="password" autocomplete="current-password" placeholder="비밀번호 확인">
<p v-if="deleteMessage" class="rounded-[8px] border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">{{ deleteMessage }}</p>
</div>
<footer class="flex justify-end gap-2 border-t border-[var(--site-line)] px-6 py-4">

View File

@@ -74,6 +74,8 @@ const mapSiteSettingsRow = (row) => ({
description: row.description,
siteUrl: row.site_url,
logoText: row.logo_text,
logoUrl: row.logo_url || '',
faviconUrl: row.favicon_url || '',
copyrightText: row.copyright_text,
updatedAt: row.updated_at.toISOString()
})
@@ -749,6 +751,8 @@ export const updateSiteSettings = async (input) => {
description,
site_url,
logo_text,
logo_url,
favicon_url,
copyright_text,
updated_at
)
@@ -757,7 +761,9 @@ export const updateSiteSettings = async (input) => {
${input.title},
${input.description},
${input.siteUrl},
${input.logoText},
${input.logoText || '井'},
${input.logoUrl || ''},
${input.faviconUrl || ''},
${input.copyrightText},
now()
)
@@ -767,6 +773,8 @@ export const updateSiteSettings = async (input) => {
description = EXCLUDED.description,
site_url = EXCLUDED.site_url,
logo_text = EXCLUDED.logo_text,
logo_url = EXCLUDED.logo_url,
favicon_url = EXCLUDED.favicon_url,
copyright_text = EXCLUDED.copyright_text,
updated_at = now()
RETURNING *
@@ -775,6 +783,42 @@ export const updateSiteSettings = async (input) => {
return mapSiteSettingsRow(rows[0])
}
/**
* 사이트 로고 URL을 수정한다.
* @param {{ logoUrl: string, faviconUrl: string }} input - 로고 URL
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export const updateSiteLogo = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
INSERT INTO site_settings (
id,
logo_url,
favicon_url,
updated_at
)
VALUES (
1,
${input.logoUrl},
${input.faviconUrl},
now()
)
ON CONFLICT (id) DO UPDATE
SET
logo_url = EXCLUDED.logo_url,
favicon_url = EXCLUDED.favicon_url,
updated_at = now()
RETURNING *
`
return mapSiteSettingsRow(rows[0])
}
/**
* 네비게이션 항목 목록 조회
* @param {Object} options - 조회 옵션

View File

@@ -0,0 +1,118 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { updateSiteLogo } from '../../../../repositories/content-repository'
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
/**
* 숫자 설정값을 최소/최대 범위로 보정한다.
* @param {number} value - 원본 값
* @param {number} minimum - 최소값
* @param {number} maximum - 최대값
* @returns {number} 보정된 값
*/
const clampNumber = (value, minimum, maximum) => {
if (!Number.isFinite(value)) {
return minimum
}
if (value < minimum) {
return minimum
}
if (value > maximum) {
return maximum
}
return Math.round(value)
}
/**
* 사이트 로고 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const config = useRuntimeConfig()
const maxFileSize = Number(config.maxFileSize || 10485760)
const logoSize = clampNumber(Number(config.siteLogoSize || 512), 128, 2048)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
if (!file) {
throw createError({
statusCode: 400,
message: '업로드할 로고 이미지가 없습니다.'
})
}
if (!allowedImageTypes.has(file.type)) {
throw createError({
statusCode: 400,
message: 'JPG, PNG, WebP 이미지만 로고로 사용할 수 있습니다.'
})
}
if (file.data.length > maxFileSize) {
throw createError({
statusCode: 413,
message: '업로드 가능한 파일 크기를 초과했습니다.'
})
}
const metadata = await sharp(file.data).metadata()
if (!metadata.width || !metadata.height) {
throw createError({
statusCode: 400,
message: '이미지 메타데이터를 읽을 수 없습니다.'
})
}
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const logoPath = join(directoryPath, 'logo.webp')
const faviconPath = join(directoryPath, 'favicon.png')
const logoUrl = `${uploadBaseUrl}/system/logo.webp`
const faviconUrl = `${uploadBaseUrl}/system/favicon.png`
await mkdir(directoryPath, { recursive: true })
const logoBuffer = await sharp(file.data)
.rotate()
.resize({
width: logoSize,
height: logoSize,
fit: 'cover',
position: 'centre'
})
.webp({ quality: 90 })
.toBuffer()
const faviconBuffer = await sharp(file.data)
.rotate()
.resize({
width: 256,
height: 256,
fit: 'cover',
position: 'centre'
})
.png()
.toBuffer()
await writeFile(logoPath, logoBuffer)
await writeFile(faviconPath, faviconBuffer)
await upsertMediaMetadataCategory(logoUrl, '시스템')
await upsertMediaMetadataCategory(faviconUrl, '시스템')
return updateSiteLogo({
logoUrl,
faviconUrl
})
})

View File

@@ -2,9 +2,11 @@ import { z } from 'zod'
export const adminSiteSettingsInputSchema = z.object({
title: z.string().trim().min(1),
description: z.string().trim().default(''),
description: z.string().trim().min(1),
siteUrl: z.string().trim().url(),
logoText: z.string().trim().min(1).max(8),
logoText: z.string().trim().max(8).optional().default('井'),
logoUrl: z.string().trim().max(500).optional().default(''),
faviconUrl: z.string().trim().max(500).optional().default(''),
copyrightText: z.string().trim().min(1)
})

View File

@@ -11,6 +11,8 @@ export const getDefaultSiteSettings = () => {
description: 'sori.studio 개인 블로그',
siteUrl: config.public.siteUrl || 'https://sori.studio',
logoText: '井',
logoUrl: '',
faviconUrl: '',
copyrightText: `©${new Date().getFullYear()} ${title}`,
updatedAt: null
}