@@ -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)
|
||||
|
||||
@@ -43,4 +43,8 @@ export const authApi = {
|
||||
points() {
|
||||
return http.get('/auth/points')
|
||||
},
|
||||
// 현재 사용자 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화
|
||||
profile() {
|
||||
return http.get('/auth/profile')
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user