feat: 게시판 추천/비추천·작성자 아바타·추천자·포인트 (프론트)
CI / build (push) Failing after 13m27s

- UserAvatar 컴포넌트(커스텀>구글>이니셜, 로드 실패 시 이니셜 폴백)
- 목록: 작성자 아바타 + 추천 수 표기
- 상세: 작성자 아바타, 추천/비추천 토글 버튼(내 투표 강조),
  추천자 본문 하단 아바타 10개 + 초과 시 (+N명이 추천했습니다), 클릭 시 전체 목록 모달
- 댓글: 작성자 아바타 + 추천/비추천 버튼
- 계정정보: 포인트 표기(GET /auth/points 최신값)
- boardApi.votePost/voteComment/recommenders, authApi.points 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 12:28:20 +09:00
parent 7855298eac
commit ca66c1d53e
6 changed files with 372 additions and 5 deletions
+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;