Merge branch 'dev'
Deploy / deploy (push) Failing after 15m5s

This commit is contained in:
ByungCheol
2026-06-28 13:21:39 +09:00
6 changed files with 220 additions and 24 deletions
+2
View File
@@ -45,6 +45,8 @@ onMounted(() => {
window.addEventListener('auth:unauthorized', onUnauthorized)
window.addEventListener('premium:required', onPremiumRequired)
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
if (auth.isAuthenticated) auth.refreshProfile()
})
onUnmounted(() => {
window.removeEventListener('auth:unauthorized', onUnauthorized)
+4
View File
@@ -43,4 +43,8 @@ export const authApi = {
points() {
return http.get('/auth/points')
},
// 현재 사용자 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화
profile() {
return http.get('/auth/profile')
},
}
+25 -1
View File
@@ -1,10 +1,11 @@
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { boardLabel } from '@/constants/boards'
import IconBtn from '@/components/ui/IconBtn.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const auth = useAuthStore()
const ui = useUiStore()
@@ -55,7 +56,15 @@ async function handleLogout() {
<div class="header-right">
<template v-if="auth.isAuthenticated">
<RouterLink to="/settings/account" class="me" title="계정정보">
<UserAvatar
:name="auth.user?.name || auth.user?.loginId"
:google-picture="auth.user?.googlePicture"
:profile-image="auth.user?.profileImage"
:size="26"
/>
<span class="username">{{ auth.user?.name || auth.user?.loginId }}</span>
</RouterLink>
<IconBtn icon="logout" title="로그아웃" @click="handleLogout" />
</template>
<IconBtn v-else icon="login" title="로그인" variant="primary" @click="ui.openLogin()" />
@@ -105,9 +114,24 @@ async function handleLogout() {
align-items: center;
gap: 0.75rem;
}
.me {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--color-text);
}
.me:hover {
opacity: 0.85;
}
.username {
font-size: 0.9rem;
}
@media (max-width: 480px) {
/* 좁은 폰 화면: 이름 숨기고 아바타만 */
.username {
display: none;
}
}
@media (max-width: 768px) {
.hamburger {
+12 -1
View File
@@ -105,6 +105,17 @@ export const useAuthStore = defineStore('auth', () => {
await persist()
}
// 서버에서 전체 프로필(아바타·포인트 포함)을 받아 병합 — 재로그인 없이 최신화
async function refreshProfile() {
if (!token.value) return
try {
const me = await authApi.profile()
await applyUser(me)
} catch {
// 실패해도 캐시된 사용자 정보 유지
}
}
async function clear() {
exitDemo()
token.value = ''
@@ -112,7 +123,7 @@ export const useAuthStore = defineStore('auth', () => {
await persist()
}
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear }
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
})
function safeParse(value) {
+133 -11
View File
@@ -39,6 +39,26 @@ function canModifyComment(c) {
return auth.user.id === c.authorId || isAdmin.value
}
// 본인 글/댓글 여부 (추천/비추천 차단용)
const isMyPost = computed(() => !!post.value && auth.user?.id === post.value.authorId)
function isMyComment(c) {
return auth.user?.id === c.authorId
}
// 본문 작성자가 단 댓글인지 (작성자 뱃지)
function isAuthorComment(c) {
return !!post.value && c.authorId === post.value.authorId
}
// 비추천 20개 이상 블라인드 + 펼치기 상태
const BLIND_DOWN = 20
function isBlinded(item) {
return (item?.downCount || 0) >= BLIND_DOWN
}
const postRevealed = ref(false)
const revealedComments = ref({})
function toggleReveal(c) {
revealedComments.value[c.id] = !revealedComments.value[c.id]
}
async function load() {
loading.value = true
error.value = null
@@ -106,6 +126,21 @@ async function removeComment(c) {
}
}
// 댓글 목록만 새로고침 (다른 사람이 단 댓글·추천 반영)
const refreshingComments = ref(false)
async function refreshComments() {
if (refreshingComments.value) return
refreshingComments.value = true
try {
const fresh = await boardApi.get(postId)
post.value.comments = fresh.comments
} catch (e) {
alert(e.response?.data?.message || '댓글을 새로고침하지 못했습니다.')
} finally {
refreshingComments.value = false
}
}
// ===== 추천/비추천 =====
const voting = ref(false)
async function votePost(type) {
@@ -178,17 +213,27 @@ onMounted(load)
🚫 관리자에 의해 열람이 제한된 게시글입니다.
</div>
<!-- 비추천 많은 블라인드 (작성자·관리자는 정상 표시) -->
<div v-else-if="isBlinded(post) && !isMyPost && !isAdmin && !postRevealed" class="blinded">
🙈 비추천이 많아 블라인드 처리된 게시글입니다.
<button type="button" class="reveal-btn" @click="postRevealed = true">그래도 보기</button>
</div>
<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')"
:disabled="voting || isMyPost"
:title="isMyPost ? '본인 글은 추천할 수 없습니다' : ''"
@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')"
:disabled="voting || isMyPost"
:title="isMyPost ? '본인 글은 비추천할 수 없습니다' : ''"
@click="votePost('DOWN')"
>👎 비추천 <b>{{ post.downCount || 0 }}</b></button>
</div>
@@ -249,7 +294,7 @@ onMounted(load)
<h2>댓글 {{ post.comments.length }}</h2>
<ul class="comment-list">
<li v-for="c in post.comments" :key="c.id" class="comment">
<li v-for="c in post.comments" :key="c.id" class="comment" :class="{ 'hot-comment': c.hot }">
<div class="comment-head">
<UserAvatar
:name="c.authorName"
@@ -258,26 +303,46 @@ onMounted(load)
:size="16"
/>
<strong>{{ c.authorName }}</strong>
<span v-if="isAuthorComment(c)" class="author-badge">작성자</span>
<span v-if="c.hot" class="hot-badge">🔥 인기</span>
<span class="comment-date">{{ formatRelative(c.createdAt) }}</span>
<span v-if="canModifyComment(c)" class="comment-del">
<IconBtn icon="trash" title="댓글 삭제" variant="danger" size="sm" @click="removeComment(c)" />
</span>
</div>
<!-- 비추천 많은 댓글 블라인드 (작성자·관리자는 정상) -->
<div v-if="isBlinded(c) && !isMyComment(c) && !isAdmin && !revealedComments[c.id]" class="blinded comment-blinded">
🙈 비추천이 많아 블라인드된 댓글입니다.
<button type="button" class="reveal-btn" @click="toggleReveal(c)">그래도 보기</button>
</div>
<template v-else>
<MarkdownViewer :content="c.content" class="comment-body" />
<div class="comment-votes">
<button
type="button" class="cvote up" :class="{ active: c.myVote === 'UP' }"
:disabled="isMyComment(c)"
:title="isMyComment(c) ? '본인 댓글은 추천할 수 없습니다' : ''"
@click="voteComment(c, 'UP')"
>👍 {{ c.upCount || 0 }}</button>
<button
type="button" class="cvote down" :class="{ active: c.myVote === 'DOWN' }"
:disabled="isMyComment(c)"
:title="isMyComment(c) ? '본인 댓글은 비추천할 수 없습니다' : ''"
@click="voteComment(c, 'DOWN')"
>👎 {{ c.downCount || 0 }}</button>
</div>
</template>
</li>
</ul>
<p v-if="!post.comments.length" class="msg"> 댓글을 남겨보세요.</p>
<!-- 댓글 목록 최하단(입력폼 ) 새로고침 -->
<div class="comment-refresh">
<button type="button" class="refresh-btn" :disabled="refreshingComments" @click="refreshComments">
🔄 {{ refreshingComments ? '새로고침 ' : '댓글 새로고침' }}
</button>
</div>
<form v-if="!post.blocked" class="comment-form" @submit.prevent="addComment">
<MarkdownEditor
v-model="commentText"
@@ -393,21 +458,22 @@ h1 {
.rec-inline-list {
list-style: none;
margin: 0.4rem 0 0;
padding: 0.3rem 0.5rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.1rem;
flex-wrap: wrap;
gap: 0.45rem 0.8rem;
}
.rec-inline-list li {
display: flex;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.4rem;
gap: 0.35rem;
white-space: nowrap; /* 이름이 잘리지 않도록 아바타+이름 단위로 줄바꿈 */
flex: none;
}
.rec-name {
font-size: 0.9rem;
font-size: 0.85rem;
}
.tags {
display: flex;
@@ -519,11 +585,67 @@ button.notice-btn {
.comment-body {
margin-top: 0.3rem;
}
.author-badge {
padding: 0.05rem 0.35rem;
border-radius: 3px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
}
.hot-comment {
background: rgba(231, 76, 60, 0.05);
border-radius: 6px;
}
.hot-badge {
padding: 0.05rem 0.35rem;
border-radius: 3px;
background: #e74c3c;
color: #fff;
font-size: 0.68rem;
font-weight: 700;
}
.comment-votes {
display: flex;
gap: 0.4rem;
margin-top: 0.4rem;
}
/* 블라인드 */
.blinded {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 1.5rem 1rem;
border: 1px dashed var(--color-border);
border-radius: 8px;
color: var(--color-text);
opacity: 0.85;
font-size: 0.92rem;
}
.comment-blinded {
padding: 0.7rem 0.8rem;
margin-top: 0.3rem;
font-size: 0.85rem;
}
.reveal-btn {
padding: 0.3rem 0.8rem;
border-radius: 999px;
font-size: 0.82rem;
}
/* 댓글 새로고침 */
.comment-refresh {
display: flex;
justify-content: center;
margin: 0.5rem 0 0.25rem;
}
.refresh-btn {
padding: 0.35rem 1rem;
border-radius: 999px;
font-size: 0.85rem;
color: var(--color-text);
}
.cvote {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
+33
View File
@@ -13,6 +13,15 @@ const router = useRouter()
const auth = useAuthStore()
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
// 비추천 20개 이상이면 블라인드 (작성자·관리자에겐 정상 표시)
const BLIND_DOWN = 20
function isBlinded(p) {
return (p.downCount || 0) >= BLIND_DOWN
}
function showBlindMsg(p) {
return isBlinded(p) && !isAdmin.value && auth.user?.id !== p.authorId
}
// 현재 게시판(카테고리)
const category = computed(() => route.params.category || DEFAULT_BOARD)
@@ -165,12 +174,17 @@ onMounted(loadTags)
<tr v-for="p in posts" :key="p.id" :class="{ 'notice-row': p.notice }" @click="router.push({ path: `/board/${category}/${p.id}`, query: route.query })">
<td>
<span v-if="p.notice" class="notice-badge">공지</span>
<span v-if="p.hot" class="hot-badge">🔥 인기</span>
<template v-if="p.blocked && !isAdmin">
<span class="blocked-msg">🚫 관리자에 의해 제한된 게시글입니다.</span>
</template>
<template v-else-if="showBlindMsg(p)">
<span class="blocked-msg">🙈 비추천이 많아 블라인드된 게시글입니다.</span>
</template>
<template v-else>
<span class="title">{{ p.title }}</span>
<span v-if="p.blocked" class="blocked-badge">제한</span>
<span v-if="isBlinded(p)" class="blind-badge">블라인드</span>
<span v-if="p.commentCount" class="comment-count">[{{ p.commentCount }}]</span>
</template>
</td>
@@ -374,6 +388,25 @@ button:disabled {
.notice-row .title {
font-weight: 600;
}
.hot-badge {
margin-right: 0.4rem;
padding: 0.05rem 0.4rem;
border-radius: 3px;
background: #e74c3c;
color: #fff;
font-size: 0.72rem;
font-weight: 700;
vertical-align: middle;
}
.blind-badge {
margin-left: 0.35rem;
padding: 0.05rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: 3px;
color: var(--color-text);
opacity: 0.6;
font-size: 0.72rem;
}
/* 하단 영역: 페이징(가운데) + 돋보기(오른쪽) 같은 행 */
.list-footer {