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 @@ + + + + + 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 () => {

{{ formInfo }}

+ + + + +
+
+ +
+ + + + +
+
{{ t }} @@ -162,6 +239,12 @@ onMounted(load)

첫 댓글을 남겨보세요.

@@ -186,6 +279,29 @@ onMounted(load) + + + +
+ +
+
@@ -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; } diff --git a/src/views/board/BoardListView.vue b/src/views/board/BoardListView.vue index 3b1f83d..2e857ff 100644 --- a/src/views/board/BoardListView.vue +++ b/src/views/board/BoardListView.vue @@ -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) {{ p.title }} 제한 [{{ p.commentCount }}] + 👍 {{ p.upCount }} - {{ p.authorName }} + + + + {{ p.authorName }} + + {{ p.viewCount }} {{ formatRelative(p.createdAt) }} @@ -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; diff --git a/src/views/settings/AccountInfoView.vue b/src/views/settings/AccountInfoView.vue index 35c94a3..e252a5e 100644 --- a/src/views/settings/AccountInfoView.vue +++ b/src/views/settings/AccountInfoView.vue @@ -1,16 +1,81 @@