diff --git a/electron/main.cjs b/electron/main.cjs
index fd79574..c5953cd 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -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 = `
돈돼지 가계부
+
+
🐷
돈돼지 가계부
+
불러오는 중…
`
+ return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
+}
+
// 오프라인/로드 실패 시 안내 화면 (다시 시도 버튼)
function offlineHtml() {
const html = `돈돼지 가계부
@@ -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()
diff --git a/src/api/authApi.js b/src/api/authApi.js
index ede1e61..976cd30 100644
--- a/src/api/authApi.js
+++ b/src/api/authApi.js
@@ -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')
+ },
}
diff --git a/src/api/boardApi.js b/src/api/boardApi.js
index 0b97b62..45646d6 100644
--- a/src/api/boardApi.js
+++ b/src/api/boardApi.js
@@ -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 })
},
diff --git a/src/components/UserAvatar.vue b/src/components/UserAvatar.vue
new file mode 100644
index 0000000..3f5021a
--- /dev/null
+++ b/src/components/UserAvatar.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+ {{ initial }}
+
+
+
+
diff --git a/src/utils/avatar.js b/src/utils/avatar.js
new file mode 100644
index 0000000..85302fd
--- /dev/null
+++ b/src/utils/avatar.js
@@ -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
+ })
+}
diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue
index 7219e33..92e98d4 100644
--- a/src/views/account/AccountView.vue
+++ b/src/views/account/AccountView.vue
@@ -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 () => {