Merge branch 'dev'
Deploy / deploy (push) Failing after 11m57s

This commit is contained in:
ByungCheol
2026-06-28 12:31:55 +09:00
9 changed files with 685 additions and 22 deletions
+80 -12
View File
@@ -1,7 +1,13 @@
// Slim Budget 데스크톱(Electron) 메인 프로세스.
// 돈돼지 가계부 데스크톱(Electron) 메인 프로세스.
// 라이브 사이트(https://app.sblog.kr)를 직접 로드한다 → 프론트 변경이 자동 반영(재설치 불필요),
// 같은 출처라 CORS 우회도 불필요. 배포된 웹 게이트가 Electron 을 감지해 안내페이지 대신 전체 앱을 띄운다.
const { app, BrowserWindow, shell, session, ipcMain, Menu } = require('electron')
//
// 로딩 UX: 예전에는 시작 시 HTTP 캐시를 통째로 비워 매번 전체 자산을 재다운로드 → 흰 화면이 오래 노출됐다.
// 이제 index.html 만 캐시버스트(?_=t)로 항상 최신을 받고, 해시된 정적 자산(JS/CSS)은 캐시를 유지해
// 재시작이 빠르다. 로드되는 동안에는 스플래시(로딩) 창을 띄워 빈 화면을 가린다.
// 또한 단일 인스턴스 잠금으로 중복 실행을 막아(두 번째 실행 시 사이트를 다시 로드하며 빈 화면이 보이던 문제),
// 이미 실행 중이면 기존 창을 활성화한다.
const { app, BrowserWindow, shell, ipcMain, Menu } = require('electron')
const fs = require('fs')
const path = require('path')
@@ -14,6 +20,24 @@ app.setName('돈돼지 가계부')
const APP_URL = 'https://app.sblog.kr'
// 현재 메인 창 참조(중복 실행 시 활성화 대상).
let mainWindow = null
// 중복 실행 방지: 잠금을 못 얻으면 이미 실행 중 → 새 인스턴스를 즉시 종료한다.
// (두 번째 실행에서 라이브 사이트를 다시 로드하느라 빈 화면이 보이던 현상 해결)
if (!app.requestSingleInstanceLock()) {
app.quit()
} else {
// 두 번째 실행 시도가 들어오면 기존 창을 복원/활성화.
app.on('second-instance', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore()
if (!mainWindow.isVisible()) mainWindow.show()
mainWindow.focus()
}
})
}
// 창 크기/위치를 userData 에 저장해 다음 실행 때 복원 (기본 해상도 고정 대신 사용자가 맞춘 크기 유지)
function stateFile() {
return path.join(app.getPath('userData'), 'window-state.json')
@@ -35,6 +59,23 @@ function saveState(win) {
}
}
// 스플래시(로딩) 화면 — 라이브 사이트가 첫 렌더될 때까지 빈 흰 화면 대신 표시.
function splashHtml() {
const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title>
<style>html,body{height:100%;margin:0;display:flex;align-items:center;justify-content:center;
background:#1a1a1a;color:#eee;font-family:system-ui,'Segoe UI',sans-serif;-webkit-user-select:none;user-select:none}
.box{text-align:center}
.pig{font-size:46px;line-height:1}
.name{margin-top:10px;font-size:17px;font-weight:700;letter-spacing:-0.01em}
.spin{margin:18px auto 0;width:26px;height:26px;border:3px solid rgba(255,255,255,.2);
border-top-color:#00bc7e;border-radius:50%;animation:r .8s linear infinite}
@keyframes r{to{transform:rotate(360deg)}}
.tip{margin-top:12px;font-size:12px;opacity:.5}</style></head>
<body><div class="box"><div class="pig">🐷</div><div class="name">돈돼지 가계부</div>
<div class="spin"></div><div class="tip">불러오는 중…</div></div></body></html>`
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
}
// 오프라인/로드 실패 시 안내 화면 (다시 시도 버튼)
function offlineHtml() {
const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title>
@@ -57,6 +98,7 @@ function createWindow() {
y: s?.y,
minWidth: 360,
minHeight: 560,
show: false, // 첫 렌더(ready-to-show) 전까지 숨김 → 빈 흰 화면 노출 방지(스플래시가 대신 보임)
backgroundColor: '#1a1a1a',
title: '돈돼지 가계부',
webPreferences: {
@@ -68,17 +110,49 @@ function createWindow() {
},
})
if (s?.maximized) win.maximize()
mainWindow = win
// 로딩 스플래시(프레임 없는 작은 창) — 메인이 준비되면 닫는다.
const splash = new BrowserWindow({
width: 300,
height: 300,
frame: false,
resizable: false,
movable: false,
skipTaskbar: true,
alwaysOnTop: true,
backgroundColor: '#1a1a1a',
title: '돈돼지 가계부',
})
splash.loadURL(splashHtml())
splash.center()
// 메인 창을 한 번만 노출하고 스플래시를 정리(이벤트 중복/지연 대비).
let revealed = false
const reveal = () => {
if (revealed) return
revealed = true
if (!win.isDestroyed()) win.show()
if (!splash.isDestroyed()) splash.destroy()
}
win.once('ready-to-show', reveal)
// 안전장치: 로드가 비정상적으로 지연돼도 12초 후엔 메인 창을 띄운다.
setTimeout(reveal, 12000)
// 기본 메뉴바 제거(앱 자체 내비게이션 사용)
win.removeMenu()
win.loadURL(APP_URL)
// index.html 만 캐시버스트(?_=t) → 항상 최신 프론트. 해시 자산은 캐시 유지 → 빠른 재시작.
win.loadURL(APP_URL + '/?_=' + Date.now())
// 닫을 때 창 크기/위치 저장 → 다음 실행에 복원
win.on('close', () => saveState(win))
// 메인 프레임 로드 실패(오프라인 등) 시 안내 화면. -3(aborted, 리다이렉트 등)은 무시.
win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (isMainFrame && errorCode !== -3) win.loadURL(offlineHtml())
if (isMainFrame && errorCode !== -3) {
win.loadURL(offlineHtml())
reveal() // 오프라인 안내도 보이도록 메인 창 노출
}
})
// 외부(앱 외 도메인) 링크는 기본 브라우저로, 앱 내 링크는 창에서 처리
@@ -110,14 +184,8 @@ ipcMain.handle('window:getSize', (e) => {
return win ? win.getSize() : [0, 0]
})
app.whenReady().then(async () => {
// 시작 시 HTTP 캐시 비움 → 라이브 사이트의 최신 프론트를 항상 로드(옛 HTML 캐시로 인한 미반영 방지).
// localStorage(로그인 세션)는 캐시가 아니라 영향 없음.
try {
await session.defaultSession.clearCache()
} catch {
/* 캐시 클리어 실패 무시 */
}
app.whenReady().then(() => {
// 캐시우지 않는다(빠른 재시작). 최신 프론트는 index.html 캐시버스트로 보장.
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
+8
View File
@@ -35,4 +35,12 @@ export const authApi = {
updateProfile(payload) {
return http.put('/auth/profile', payload)
},
// 프로필 사진 변경/해제 — image 가 null/빈값이면 해제(구글 사진으로 폴백)
updateProfileImage(image) {
return http.put('/auth/profile-image', { image })
},
// 현재 사용자 활동 포인트 (최신값)
points() {
return http.get('/auth/points')
},
}
+11
View File
@@ -47,6 +47,17 @@ export const boardApi = {
removeComment(commentId) {
return http.delete(`/board/comments/${commentId}`)
},
// 추천/비추천 (토글) — type: 'UP' | 'DOWN'
votePost(id, type) {
return http.post(`/board/posts/${id}/vote`, { type })
},
voteComment(commentId, type) {
return http.post(`/board/comments/${commentId}/vote`, { type })
},
// 추천(👍)한 사람 목록
recommenders(id) {
return http.get(`/board/posts/${id}/recommenders`)
},
block(id, reason) {
return http.post(`/board/posts/${id}/block`, { reason })
},
+62
View File
@@ -0,0 +1,62 @@
<script setup>
import { computed, ref, watch } from 'vue'
// 작성자/추천자 아바타. 우선순위: 커스텀(profileImage) > 구글(googlePicture) > 이니셜.
const props = defineProps({
name: { type: String, default: '' },
googlePicture: { type: String, default: '' },
profileImage: { type: String, default: '' },
size: { type: Number, default: 28 },
})
const broken = ref(false)
const src = computed(() => props.profileImage || props.googlePicture || '')
const initial = computed(() => {
const s = (props.name || '').trim()
return s ? s[0].toUpperCase() : '?'
})
// src 가 바뀌면 깨짐 상태 리셋
watch(src, () => { broken.value = false })
</script>
<template>
<span class="user-avatar" :style="{ width: size + 'px', height: size + 'px' }" :title="name">
<img
v-if="src && !broken"
:src="src"
:alt="name"
referrerpolicy="no-referrer"
@error="broken = true"
/>
<span v-else class="ua-initial" :style="{ fontSize: Math.round(size * 0.45) + 'px' }">{{ initial }}</span>
</span>
</template>
<style scoped>
.user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
flex: none;
vertical-align: middle;
background: var(--color-background-mute);
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ua-initial {
font-weight: 700;
color: #fff;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: hsla(160, 100%, 37%, 1);
}
</style>
+55
View File
@@ -0,0 +1,55 @@
// 프로필 아바타 유틸 — 표시 우선순위(커스텀 > 구글 > 이니셜)와 업로드용 다운스케일.
/**
* 표시할 아바타 이미지 소스를 고른다.
* 우선순위: 사용자 지정(profileImage) > 구글 아바타(googlePicture) > null(이니셜 폴백)
*/
export function avatarSrc(user) {
if (!user) return null
return user.profileImage || user.googlePicture || null
}
/** 이름/아이디에서 이니셜(첫 글자) 한 글자 — 이미지 없을 때 폴백 */
export function avatarInitial(user) {
const s = (user?.name || user?.loginId || '').trim()
return s ? s[0].toUpperCase() : '?'
}
/**
* 업로드용으로 이미지 파일을 정사각형으로 크롭·축소해 JPEG data URL 로 변환한다.
* (서버/로컬 저장 용량을 작게 — 기본 192px)
*/
export function fileToAvatarDataUrl(file, size = 192, quality = 0.85) {
return new Promise((resolve, reject) => {
if (!file || !file.type?.startsWith('image/')) {
reject(new Error('이미지 파일이 아닙니다.'))
return
}
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
// 중앙 정사각형 크롭(cover)
const min = Math.min(img.width, img.height)
const sx = (img.width - min) / 2
const sy = (img.height - min) / 2
ctx.drawImage(img, sx, sy, min, min, 0, 0, size, size)
const dataUrl = canvas.toDataURL('image/jpeg', quality)
URL.revokeObjectURL(url)
resolve(dataUrl)
} catch (e) {
URL.revokeObjectURL(url)
reject(e)
}
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('이미지를 읽지 못했습니다.'))
}
img.src = url
})
}
+8 -5
View File
@@ -803,10 +803,13 @@ async function analyzePasted() {
onMounted(async () => {
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
try {
await accountApi.runRecurrings()
} catch {
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
// 고정지출은 유료 전용 — 무료 회원은 호출 자체를 건너뛴다(403/업그레이드 리다이렉트 방지)
if (isPremium.value) {
try {
await accountApi.runRecurrings()
} catch {
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
}
}
load()
loadTagOptions()
@@ -1102,7 +1105,7 @@ onMounted(async () => {
<p v-if="formInfo" class="msg ok">{{ formInfo }}</p>
<button
v-if="editId && form.type !== 'REPAYMENT'"
v-if="isPremium && editId && form.type !== 'REPAYMENT'"
type="button"
class="to-recurring"
:disabled="submitting"
+243 -2
View File
@@ -8,6 +8,7 @@ import { formatRelative } from '@/utils/datetime'
import MarkdownViewer from '@/components/editor/MarkdownViewer.vue'
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
import IconBtn from '@/components/ui/IconBtn.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { DEFAULT_BOARD } from '@/constants/boards'
const route = useRoute()
@@ -105,6 +106,45 @@ async function removeComment(c) {
}
}
// ===== 추천/비추천 =====
const voting = ref(false)
async function votePost(type) {
if (voting.value) return
voting.value = true
try {
const res = await boardApi.votePost(postId, type)
post.value.upCount = res.upCount
post.value.downCount = res.downCount
post.value.myVote = res.myVote
// 추천 변동 → 추천자 목록 갱신
post.value.recommenders = await boardApi.recommenders(postId)
} catch (e) {
alert(e.response?.data?.message || '처리에 실패했습니다.')
} finally {
voting.value = false
}
}
async function voteComment(c, type) {
try {
const res = await boardApi.voteComment(c.id, type)
c.upCount = res.upCount
c.downCount = res.downCount
c.myVote = res.myVote
} catch (e) {
alert(e.response?.data?.message || '처리에 실패했습니다.')
}
}
// ===== 추천자 목록 =====
const recommenders = computed(() => post.value?.recommenders || [])
const previewRecommenders = computed(() => recommenders.value.slice(0, 10))
const extraRecommenders = computed(() => Math.max(0, (post.value?.upCount || 0) - 10))
const showRecommenders = ref(false)
function openRecommenders() {
if (post.value?.upCount) showRecommenders.value = true
}
onMounted(load)
</script>
@@ -117,7 +157,13 @@ onMounted(load)
<header class="post-head">
<h1><span v-if="post.notice" class="notice-badge">공지</span>{{ post.title }}</h1>
<div class="meta">
<span>{{ post.authorName }}</span>
<UserAvatar
:name="post.authorName"
:google-picture="post.authorGooglePicture"
:profile-image="post.authorProfileImage"
:size="26"
/>
<span class="meta-author">{{ post.authorName }}</span>
<span>· {{ formatRelative(post.createdAt) }}</span>
<span>· 조회 {{ post.viewCount }}</span>
</div>
@@ -136,6 +182,37 @@ onMounted(load)
<MarkdownViewer v-else :content="post.content" class="content" />
<!-- 추천 / 비추천 -->
<div v-if="!restricted" class="vote-bar">
<button
type="button" class="vote-btn up" :class="{ active: post.myVote === 'UP' }"
:disabled="voting" @click="votePost('UP')"
>👍 추천 <b>{{ post.upCount || 0 }}</b></button>
<button
type="button" class="vote-btn down" :class="{ active: post.myVote === 'DOWN' }"
:disabled="voting" @click="votePost('DOWN')"
>👎 비추천 <b>{{ post.downCount || 0 }}</b></button>
</div>
<!-- 추천자 (아바타 최대 10 + 초과 안내, 클릭 전체 목록) -->
<div v-if="!restricted && post.upCount" class="recommenders" @click="openRecommenders">
<div class="rec-avatars">
<UserAvatar
v-for="r in previewRecommenders"
:key="r.memberId"
:name="r.name"
:google-picture="r.googlePicture"
:profile-image="r.profileImage"
:size="28"
class="rec-avatar"
/>
</div>
<span class="rec-label">
<template v-if="extraRecommenders > 0">+{{ extraRecommenders }}명이 추천했습니다</template>
<template v-else>{{ post.upCount }}명이 추천했습니다</template>
</span>
</div>
<!-- 태그 (본문 아래) -->
<div v-if="!restricted && post.tags?.length" class="tags">
<span v-for="t in post.tags" :key="t" class="tag">{{ t }}</span>
@@ -162,6 +239,12 @@ onMounted(load)
<ul class="comment-list">
<li v-for="c in post.comments" :key="c.id" class="comment">
<div class="comment-head">
<UserAvatar
:name="c.authorName"
:google-picture="c.authorGooglePicture"
:profile-image="c.authorProfileImage"
:size="24"
/>
<strong>{{ c.authorName }}</strong>
<span class="comment-date">{{ formatRelative(c.createdAt) }}</span>
<span v-if="canModifyComment(c)" class="comment-del">
@@ -169,6 +252,16 @@ onMounted(load)
</span>
</div>
<MarkdownViewer :content="c.content" class="comment-body" />
<div class="comment-votes">
<button
type="button" class="cvote up" :class="{ active: c.myVote === 'UP' }"
@click="voteComment(c, 'UP')"
>👍 {{ c.upCount || 0 }}</button>
<button
type="button" class="cvote down" :class="{ active: c.myVote === 'DOWN' }"
@click="voteComment(c, 'DOWN')"
>👎 {{ c.downCount || 0 }}</button>
</div>
</li>
</ul>
<p v-if="!post.comments.length" class="msg"> 댓글을 남겨보세요.</p>
@@ -186,6 +279,29 @@ onMounted(load)
</form>
</section>
</article>
<!-- 추천자 전체 목록 (아바타 + 이름) -->
<Teleport to="body">
<div v-if="showRecommenders" class="rec-modal-backdrop" @click.self="showRecommenders = false">
<div class="rec-modal" role="dialog" aria-modal="true" aria-label="추천한 사람">
<div class="rec-modal-head">
<strong>추천한 사람 {{ post?.upCount || 0 }}</strong>
<IconBtn icon="close" title="닫기" size="sm" @click="showRecommenders = false" />
</div>
<ul class="rec-modal-list">
<li v-for="r in recommenders" :key="r.memberId">
<UserAvatar
:name="r.name"
:google-picture="r.googlePicture"
:profile-image="r.profileImage"
:size="32"
/>
<span class="rec-name">{{ r.name }}</span>
</li>
</ul>
</div>
</div>
</Teleport>
</section>
</template>
@@ -208,11 +324,74 @@ h1 {
.meta {
font-size: 0.85rem;
color: var(--color-text);
opacity: 0.75;
opacity: 0.85;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.meta-author {
font-weight: 600;
}
/* 추천/비추천 */
.vote-bar {
display: flex;
justify-content: center;
gap: 0.75rem;
padding: 0.5rem 0 1rem;
}
.vote-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 1.4rem;
border-radius: 999px;
font-size: 0.92rem;
}
.vote-btn b {
font-weight: 700;
}
.vote-btn.up.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.08);
}
.vote-btn.down.active {
border-color: #e67e22;
color: #e67e22;
background: rgba(230, 126, 34, 0.08);
}
/* 추천자 */
.recommenders {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-soft);
cursor: pointer;
margin-bottom: 0.5rem;
}
.recommenders:hover {
background: var(--color-background-mute);
}
.rec-avatars {
display: flex;
}
.rec-avatar {
margin-left: -6px;
box-shadow: 0 0 0 2px var(--color-background-soft);
}
.rec-avatar:first-child {
margin-left: 0;
}
.rec-label {
font-size: 0.85rem;
color: var(--color-text);
opacity: 0.85;
}
.tags {
display: flex;
gap: 0.4rem;
@@ -323,6 +502,68 @@ button.notice-btn {
.comment-body {
margin-top: 0.3rem;
}
.comment-votes {
display: flex;
gap: 0.4rem;
margin-top: 0.4rem;
}
.cvote {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
border-radius: 999px;
}
.cvote.up.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.cvote.down.active {
border-color: #e67e22;
color: #e67e22;
}
/* 추천자 모달 */
.rec-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.rec-modal {
width: 100%;
max-width: 360px;
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.rec-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.rec-modal-list {
list-style: none;
margin: 0;
padding: 0.5rem 0;
overflow-y: auto;
}
.rec-modal-list li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 1rem;
}
.rec-name {
font-size: 0.95rem;
}
.comment-form {
margin-top: 1rem;
}
+31 -2
View File
@@ -6,6 +6,7 @@ import { formatRelative } from '@/utils/datetime'
import { useAuthStore } from '@/stores/auth'
import { DEFAULT_BOARD } from '@/constants/boards'
import IconBtn from '@/components/ui/IconBtn.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const route = useRoute()
const router = useRouter()
@@ -170,9 +171,20 @@ onMounted(loadTags)
<span class="title">{{ p.title }}</span>
<span v-if="p.blocked" class="blocked-badge">제한</span>
<span v-if="p.commentCount" class="comment-count">[{{ p.commentCount }}]</span>
<span v-if="p.upCount" class="recommend-count">👍 {{ p.upCount }}</span>
</template>
</td>
<td class="col-author">{{ p.authorName }}</td>
<td class="col-author">
<span class="author-cell">
<UserAvatar
:name="p.authorName"
:google-picture="p.authorGooglePicture"
:profile-image="p.authorProfileImage"
:size="22"
/>
<span class="author-name">{{ p.authorName }}</span>
</span>
</td>
<td class="col-num">{{ p.viewCount }}</td>
<td class="col-date">{{ formatRelative(p.createdAt) }}</td>
</tr>
@@ -307,7 +319,18 @@ button:disabled {
text-align: center;
}
.col-author {
width: 100px;
width: 130px;
}
.author-cell {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.author-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-date {
width: 110px;
@@ -318,6 +341,12 @@ button:disabled {
color: hsla(160, 100%, 37%, 1);
font-size: 0.85rem;
}
.recommend-count {
margin-left: 0.35rem;
color: var(--color-text);
opacity: 0.7;
font-size: 0.82rem;
}
.blocked-msg {
color: #c0392b;
font-size: 0.9rem;
+187 -1
View File
@@ -1,16 +1,81 @@
<script setup>
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/authApi'
import { useDialog } from '@/composables/dialog'
import { avatarSrc, avatarInitial, fileToAvatarDataUrl } from '@/utils/avatar'
const auth = useAuthStore()
const router = useRouter()
const dialog = useDialog()
// 포인트(게시판 작성으로 수시 변동) — 진입 시 최신값 조회
const points = ref(auth.user?.points ?? 0)
onMounted(async () => {
try {
const res = await authApi.points()
points.value = res.points ?? 0
await auth.applyUser({ points: points.value })
} catch {
// 조회 실패 시 캐시값 유지
}
})
const u = computed(() => auth.user || {})
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
const isPremium = computed(() => auth.isPremium)
const planLabel = computed(() => (isPremium.value ? '유료 멤버십' : '무료'))
// 아바타: 사용자 지정 > 구글 > 이니셜
const avatar = computed(() => avatarSrc(u.value))
const initial = computed(() => avatarInitial(u.value))
const hasCustom = computed(() => !!u.value.profileImage)
const hasGoogle = computed(() => !!u.value.googlePicture)
const fileInput = ref(null)
const savingImage = ref(false)
const imgError = ref('')
function pickImage() {
imgError.value = ''
fileInput.value?.click()
}
async function onImageFile(e) {
const file = e.target.files?.[0]
e.target.value = '' // 같은 파일 다시 선택 가능하도록 초기화
if (!file) return
savingImage.value = true
imgError.value = ''
try {
const dataUrl = await fileToAvatarDataUrl(file)
const updated = await authApi.updateProfileImage(dataUrl)
await auth.applyUser(updated)
} catch (err) {
imgError.value = err.response?.data?.message || '사진 변경에 실패했습니다.'
} finally {
savingImage.value = false
}
}
async function resetImage() {
const msg = hasGoogle.value
? '사용자 지정 사진을 지우고 구글 사진으로 되돌릴까요?'
: '사용자 지정 사진을 지울까요?'
if (!(await dialog.confirm(msg, { title: '사진 되돌리기' }))) return
savingImage.value = true
imgError.value = ''
try {
const updated = await authApi.updateProfileImage(null)
await auth.applyUser(updated)
} catch (err) {
imgError.value = err.response?.data?.message || '사진 되돌리기에 실패했습니다.'
} finally {
savingImage.value = false
}
}
function goEdit() {
router.push('/settings/account/edit')
}
@@ -19,6 +84,30 @@ function goEdit() {
<template>
<div class="account-info">
<!-- 프로필 사진 -->
<div class="avatar-block">
<button type="button" class="avatar-btn" :disabled="savingImage" @click="pickImage" aria-label="프로필 사진 변경">
<img v-if="avatar" :src="avatar" class="avatar-img" alt="프로필 사진" referrerpolicy="no-referrer" />
<span v-else class="avatar-fallback">{{ initial }}</span>
<span class="avatar-cam" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
</span>
</button>
<div class="avatar-actions">
<button type="button" class="link-btn" :disabled="savingImage" @click="pickImage">
{{ savingImage ? '처리 ' : '사진 변경' }}
</button>
<button v-if="hasCustom" type="button" class="link-btn muted" :disabled="savingImage" @click="resetImage">
{{ hasGoogle ? '구글 사진으로' : '기본으로' }}
</button>
</div>
<p v-if="imgError" class="avatar-err">{{ imgError }}</p>
<input ref="fileInput" type="file" accept="image/*" class="hidden-file" @change="onImageFile" />
</div>
<section class="card">
<div class="row">
<span class="k">이름</span>
@@ -39,6 +128,10 @@ function goEdit() {
<RouterLink v-if="!isPremium" to="/upgrade" class="up-link">업그레이드 </RouterLink>
</span>
</div>
<div class="row">
<span class="k">포인트</span>
<span class="v"><span class="points">{{ points.toLocaleString('ko-KR') }} P</span></span>
</div>
</section>
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
@@ -104,12 +197,105 @@ function goEdit() {
.v .premium {
color: #b8860b;
}
.points {
color: hsla(160, 100%, 37%, 1);
font-weight: 700;
}
.up-link {
margin-left: 0.5rem;
font-size: 0.82rem;
font-weight: 600;
color: hsla(160, 100%, 37%, 1);
}
.avatar-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.25rem;
}
.avatar-btn {
position: relative;
width: 96px;
height: 96px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
padding: 0;
cursor: pointer;
overflow: visible;
}
.avatar-btn:disabled {
opacity: 0.6;
cursor: default;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block;
}
.avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
font-size: 2.2rem;
font-weight: 700;
color: #fff;
background: hsla(160, 100%, 37%, 1);
}
.avatar-cam {
position: absolute;
right: -2px;
bottom: -2px;
width: 30px;
height: 30px;
border-radius: 50%;
background: hsla(160, 100%, 37%, 1);
border: 2px solid var(--color-background);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.avatar-cam svg {
width: 16px;
height: 16px;
}
.avatar-actions {
display: flex;
gap: 0.85rem;
align-items: center;
}
.link-btn {
border: 0;
background: transparent;
color: hsla(160, 100%, 37%, 1);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0.2rem 0.1rem;
}
.link-btn.muted {
color: var(--color-text);
opacity: 0.6;
}
.link-btn:disabled {
opacity: 0.5;
cursor: default;
}
.avatar-err {
font-size: 0.82rem;
color: #c0392b;
margin: 0;
}
.hidden-file {
display: none;
}
.primary-btn {
width: 100%;
margin-top: 1.25rem;