feat: 프로필 이미지(avatarUrl) 필드 + UserAvatar 도입, 색상·이니셜 아바타 제거

- 백엔드: User.avatarUrl(nullable) 추가 → PublicUser/toPublic 로 모든 유저 응답에 노출.
- 프론트: 공용 UserAvatar(이미지 있으면 img, 없으면 사람 실루엣 placeholder)로
  전 화면 아바타 통일(상단바·저장소·멤버·담당자·활동·내업무·댓글·AI패널).
  화면마다 달랐던 색상+이니셜/하드코딩 청록 아바타 제거.
- 죽은 코드 정리: 색상·이니셜 시스템(avatarColor/initialOf/AvatarColor 유틸·타입,
  User/RepoActivityItem 의 initial/color, 전역·컴포넌트 .av-a~.av-f CSS) +
  미사용 정적 목업 데이터(USERS/REPOS/REPO_TASKS/TASK_DETAIL/MY_TASKS_*/REPO_MEMBERS/
  REPO_ACTIVITY 등) 전부 삭제. relay.mock.ts 는 뷰 타입 + countActive 만 유지.

avatar_url 컬럼은 dev synchronize 가 추가. 업로드(실제 사진 설정)는 추후 단계.
검증: 백엔드 build 0, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 14:04:09 +09:00
parent fa4bc40761
commit 5351373694
23 changed files with 171 additions and 882 deletions
@@ -29,6 +29,10 @@ export class User {
@Column({ type: 'varchar', nullable: true })
role!: string | null;
// 프로필 이미지 URL — 미설정 시 null(프론트는 placeholder 표시). 업로드는 추후 단계.
@Column({ name: 'avatar_url', type: 'varchar', nullable: true })
avatarUrl!: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
+2
View File
@@ -9,6 +9,7 @@ export interface PublicUser {
email: string;
name: string;
role: string | null;
avatarUrl: string | null;
}
// 사용자 데이터 접근 계층 — 인증 모듈이 주입하여 사용
@@ -76,6 +77,7 @@ export class UserService {
email: user.email,
name: user.name,
role: user.role,
avatarUrl: user.avatarUrl ?? null,
};
}
}
-18
View File
@@ -189,24 +189,6 @@ body {
color: #fff;
flex-shrink: 0;
}
.av-a {
background: #e0518d;
}
.av-b {
background: #3d8bf2;
}
.av-c {
background: #16a37b;
}
.av-d {
background: #b3631f;
}
.av-e {
background: #7c5cf0;
}
.av-f {
background: #d0982a;
}
.av-more {
background: #eceef1;
color: var(--text-2);
+5 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 저장소 헤더 카드 — 상세/멤버/활동/설정 화면이 공유
import { computed } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import type { Repo, User } from '@/mock/relay.mock'
interface Props {
@@ -109,8 +110,10 @@ const extra = computed(() => props.moreCount ?? props.repo.moreCount)
v-for="m in avatars"
:key="m.id"
class="avatar"
:class="m.color"
>{{ m.initial }}</span>
><UserAvatar
:url="m.avatarUrl"
:name="m.name"
/></span>
<span
v-if="extra > 0"
class="avatar av-more"
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
// 공용 사용자 아바타 — 프로필 이미지가 있으면 이미지를, 없으면 사람 실루엣 placeholder 를 표시.
// (색상+이니셜 방식은 폐기) 크기/모양은 부모의 사이징 래퍼가 정하고, 이 컴포넌트는 그 안을 채운다.
interface Props {
url?: string | null // 프로필 이미지 URL (없으면 placeholder)
name?: string // 이미지 대체 텍스트(접근성)
}
defineProps<Props>()
</script>
<template>
<span class="user-avatar">
<img
v-if="url"
class="ua-img"
:src="url"
:alt="name || '프로필 이미지'"
>
<svg
v-else
class="ua-ph"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<!-- 사람 실루엣( 프로필 placeholder) -->
<path d="M12 12.5a4.25 4.25 0 1 0 0-8.5 4.25 4.25 0 0 0 0 8.5Z" />
<path d="M4.5 19.25c0-3.04 3.36-5 7.5-5s7.5 1.96 7.5 5v.75H4.5v-.75Z" />
</svg>
</span>
</template>
<style scoped>
.user-avatar {
display: grid;
place-items: center;
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
background: #e5e7eb; /* placeholder 배경(중립 회색) */
flex-shrink: 0;
}
.ua-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ua-ph {
width: 64%;
height: 64%;
color: #9aa1ac; /* placeholder 아이콘 색 */
}
</style>
+7 -4
View File
@@ -6,6 +6,7 @@ import { storeToRefs } from 'pinia'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
import { useNotificationStore } from '@/stores/notification.store'
import UserAvatar from '@/components/UserAvatar.vue'
import type { NotificationView } from '@/types/notification'
const route = useRoute()
@@ -17,9 +18,8 @@ const activeNav = computed<'tasks' | 'repos'>(() =>
route.path.startsWith('/repos') ? 'repos' : 'tasks',
)
// 현재 로그인 사용자 표시(아바타 이니셜 = 이름 첫 글자)
// 현재 로그인 사용자(프로필 아바타는 UserAvatar 가 이미지/placeholder 로 표시)
const me = computed(() => authStore.user)
const meInitial = computed(() => me.value?.name?.charAt(0) ?? '?')
// 사용자 메뉴 토글
const menuOpen = ref(false)
@@ -223,7 +223,10 @@ async function onLogout() {
:aria-expanded="menuOpen"
@click="menuOpen = !menuOpen"
>
{{ meInitial }}
<UserAvatar
:url="me?.avatarUrl"
:name="me?.name"
/>
</button>
<!-- 사용자 드롭다운 -->
@@ -522,7 +525,7 @@ async function onLogout() {
font-size: 0.719rem;
font-weight: 700;
color: #fff;
background: #0ea5a3;
/* 내부 UserAvatar 가 이미지/placeholder 로 배경을 채운다(버튼 자체 배경 없음) */
border: none;
cursor: pointer;
padding: 0;
+8 -696
View File
@@ -1,11 +1,8 @@
/**
* Relay 목업 데이터 — 프론트 UI 우선 구성용 정적 데이터
* 추후 backend API 연동 시 composable/store 에서 이 데이터를 실제 응답으로 교체한다.
* Relay 화면용 뷰 타입 정의.
* (구 목업 정적 데이터는 백엔드 연동 완료로 제거됨 — store/composable 가 실제 응답을 사용)
*/
/** 아바타 색상 키 (relay.css 의 .av-* 클래스와 대응) */
export type AvatarColor = 'av-a' | 'av-b' | 'av-c' | 'av-d' | 'av-e' | 'av-f'
/** 업무 상태 */
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes'
@@ -17,8 +14,8 @@ export interface User {
id: string
name: string
role: string
initial: string
color: AvatarColor
/** 프로필 이미지 URL — 없으면 UserAvatar 가 placeholder 표시 */
avatarUrl?: string | null
}
/** 첨부파일 */
@@ -152,322 +149,6 @@ export interface TaskDetail {
issuedLabel: string
}
/* ============================================================
* 사용자
* ============================================================ */
// satisfies 로 선언해 각 키 접근이 `User | undefined` 로 넓어지지 않도록 한다
// (tsconfig 의 noUncheckedIndexedAccess 대응)
export const USERS = {
kim: { id: 'kim', name: '김서연', role: '콘텐츠 기획', initial: '김', color: 'av-a' },
park: { id: 'park', name: '박지훈', role: '퍼포먼스 마케팅', initial: '박', color: 'av-b' },
lee: { id: 'lee', name: '이도윤', role: '카피라이팅', initial: '이', color: 'av-c' },
choi: { id: 'choi', name: '최유진', role: 'UX 디자인', initial: '최', color: 'av-d' },
han: { id: 'han', name: '한승우', role: '브랜드 전략', initial: '한', color: 'av-e' },
jung: { id: 'jung', name: '정태경', role: '마케팅 그룹 리드', initial: '정', color: 'av-f' },
} satisfies Record<string, User>
/* ============================================================
* 저장소 목록
* ============================================================ */
export const REPOS: Repo[] = [
{
id: 'q3-launch-campaign',
name: '3분기 신제품 런칭 캠페인',
owner: 'marketing-team',
slug: 'marketing-team/q3-launch-campaign',
desc: '3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수',
visibility: 'private',
branch: 'main',
members: [USERS.kim, USERS.park, USERS.lee],
moreCount: 2,
updatedAgo: '2시간 전 업데이트',
doneCount: 8,
totalCount: 12,
progressPct: 67,
progressLevel: 'mid',
progressLabel: '67% 완료',
},
{
id: 'brand-refresh-2026',
name: '브랜드 리뉴얼 2026',
owner: 'marketing-team',
slug: 'marketing-team/brand-refresh-2026',
desc: '브랜드 아이덴티티 리뉴얼 및 가이드라인 개편 프로젝트',
visibility: 'private',
branch: 'main',
members: [USERS.choi, USERS.jung, USERS.han],
moreCount: 1,
updatedAgo: '어제 업데이트',
doneCount: 5,
totalCount: 9,
progressPct: 56,
progressLevel: 'mid',
progressLabel: '56% 완료',
},
{
id: 'monthly-newsletter',
name: '월간 뉴스레터 운영',
owner: 'marketing-team',
slug: 'marketing-team/monthly-newsletter',
desc: '월간 뉴스레터 기획 · 콘텐츠 작성 · 발송 정기 운영',
visibility: 'public',
branch: 'main',
members: [USERS.lee, USERS.park, USERS.kim],
moreCount: 0,
updatedAgo: '3일 전 업데이트',
doneCount: 14,
totalCount: 14,
progressPct: 100,
progressLevel: 'done',
progressLabel: '100% 완료',
},
{
id: 'seo-content-hub',
name: 'SEO 콘텐츠 허브',
owner: 'marketing-team',
slug: 'marketing-team/seo-content-hub',
desc: '검색 유입 강화를 위한 SEO 콘텐츠 기획 및 제작 허브',
visibility: 'private',
branch: 'main',
members: [USERS.park, USERS.han, USERS.lee],
moreCount: 3,
updatedAgo: '5시간 전 업데이트',
doneCount: 2,
totalCount: 11,
progressPct: 18,
progressLevel: 'low',
progressLabel: '18% 완료',
},
{
id: 'event-handbook',
name: '사내 행사 핸드북',
owner: 'marketing-team',
slug: 'marketing-team/event-handbook',
desc: '사내 행사 운영 가이드 및 준비 체크리스트 모음',
visibility: 'public',
branch: 'main',
members: [USERS.jung, USERS.kim],
moreCount: 0,
updatedAgo: '1주 전 업데이트',
doneCount: 0,
totalCount: 4,
progressPct: 0,
progressLevel: 'low',
progressLabel: '시작 전',
},
{
id: 'design-system-assets',
name: '디자인 시스템 에셋',
owner: 'marketing-team',
slug: 'marketing-team/design-system-assets',
desc: '공통 디자인 시스템 에셋 및 컴포넌트 관리',
visibility: 'private',
branch: 'develop',
members: [USERS.kim, USERS.choi, USERS.lee],
moreCount: 1,
updatedAgo: '30분 전 업데이트',
doneCount: 9,
totalCount: 10,
progressPct: 90,
progressLevel: 'done',
progressLabel: '90% 완료',
},
]
/* ============================================================
* 저장소 상세 — 업무 목록 (q3-launch-campaign 기준)
* ============================================================ */
export const REPO_TASKS: Record<string, RepoTask[]> = {
'q3-launch-campaign': [
{
id: 11,
title: '법무 검토 요청 및 회신 반영',
status: 'prog',
dueLabel: '2일 지남',
overdue: true,
checklist: [1, 3],
assignees: [USERS.lee],
},
{
id: 9,
title: '메인 배너 이미지 2종 (가로/세로) 디자인',
status: 'review',
dueLabel: 'D-3',
checklist: [1, 3],
comments: 2,
assignees: [USERS.park, USERS.choi],
},
{
id: 8,
title: '상세페이지 카피라이팅',
status: 'todo',
dueLabel: 'D-5',
checklist: [0, 3],
assignees: [USERS.lee],
},
{
id: 7,
title: 'SNS 콘텐츠 1차 시안',
status: 'todo',
dueLabel: 'D-8',
checklist: [0, 4],
assignees: [USERS.han],
},
{
id: 6,
title: '캠페인 핵심 메시지 시안 작성',
status: 'done',
dueLabel: '6월 9일 완료',
checklist: [3, 3],
assignees: [USERS.kim],
},
{
id: 5,
title: '레퍼런스 리서치 및 무드보드 정리',
status: 'done',
dueLabel: '6월 6일 완료',
assignees: [USERS.kim],
},
{
id: 4,
title: '퍼포먼스 광고 소재 세팅',
status: 'done',
dueLabel: '6월 5일 완료',
checklist: [2, 2],
assignees: [USERS.park],
},
{
id: 3,
title: '타깃 오디언스 정의 및 페르소나 정리',
status: 'done',
dueLabel: '6월 4일 완료',
assignees: [USERS.park],
},
{
id: 1,
title: '킥오프 미팅 및 일정 수립',
status: 'done',
dueLabel: '6월 2일 완료',
assignees: [USERS.jung],
},
],
}
/* ============================================================
* 업무 상세 (#9)
* ============================================================ */
export const TASK_DETAIL: TaskDetail = {
id: 9,
repoId: 'q3-launch-campaign',
repoName: '3분기 신제품 런칭 캠페인',
title: '메인 배너 이미지 2종 (가로/세로) 디자인',
status: 'review',
statusLabel: '승인 대기',
content: [
'9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 @브랜드_가이드라인.pdf 기준을 따라주세요.',
'퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.',
],
checklist: [
{ text: '가로형 메인 배너 1920×1080 디자인', done: true },
{ text: '세로형 메인 배너 1080×1920 디자인', done: false },
{ text: '모바일 적응형 600×600 제작', done: false },
],
comments: [
{
author: USERS.jung,
roleBadge: '지시자',
time: '2일 전',
text: '가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.',
replies: [
{ author: USERS.park, time: '2일 전', text: '네, 가로형 정렬 기준으로 먼저 잡겠습니다.' },
],
},
{
author: USERS.park,
time: '1일 전',
text: '가로형 1차 시안 업로드했습니다. 검토 부탁드려요.',
file: 'main_banner_h_v1.png',
replies: [],
},
{
author: USERS.choi,
time: '3시간 전',
text: '세로형은 내일 오전까지 공유하겠습니다.',
replies: [],
},
],
activities: [
{
tone: 'amber',
icon: 'check-circle',
html: '<b>박지훈</b>님이 승인을 요청했습니다',
statusFrom: '진행 중',
statusTo: '승인 대기',
time: '2시간 전 · 오후 3:24',
},
{
tone: 'green',
icon: 'check',
html: '<b>박지훈</b>님이 <b>가로형 메인 배너 1920×1080 디자인</b> 항목을 완료했습니다',
time: '1일 전 · 오후 6:10',
},
{
tone: '',
icon: 'paperclip',
html: '<b>박지훈</b>님이 파일을 첨부했습니다',
file: 'main_banner_h_v1.png',
time: '1일 전 · 오후 6:08',
},
{
tone: 'blue',
icon: 'calendar',
html: '<b>정태경</b>님이 마감기한을 <b>6월 18일 (목)</b>로 설정했습니다',
time: '6월 8일 · 오전 10:32',
},
{
tone: 'blue',
icon: 'users',
html: '<b>정태경</b>님이 <b>박지훈</b>, <b>최유진</b>님을 담당자로 지정했습니다',
time: '6월 8일 · 오전 10:31',
},
{
tone: 'accent',
icon: 'flag',
html: '<b>정태경</b>님이 업무를 생성했습니다',
time: '6월 8일 · 오전 10:30',
},
],
assignees: [USERS.park, USERS.choi],
issuer: USERS.jung,
dueLabel: '6월 18일 (목)',
dday: 'D-3',
files: [
{ name: '레퍼런스_무드보드.png', size: '3.2 MB', kind: 'img' },
{ name: '배너_소스_파일.zip', size: '18.4 MB', kind: 'zip' },
],
approval: {
requester: '박지훈',
requestedAgo: '2시간 전',
note: '가로형/세로형 메인 배너 1차 제작을 완료했습니다. 모바일 적응형은 컨펌 후 진행 예정이며, 우선 두 비율 검토 부탁드립니다.',
},
issuedLabel: '6월 8일 지시 · #9',
}
/* ============================================================
* 조회 헬퍼
* ============================================================ */
export function findRepo(id: string): Repo | undefined {
return REPOS.find((r) => r.id === id)
}
export function findRepoTasks(id: string): RepoTask[] {
return REPO_TASKS[id] ?? []
}
/* ============================================================
* 내 업무 (담당 / 지시) — 상태 그룹 단위 정적 데이터
* ============================================================ */
/** D-day 강조 단계 */
export type DdayLevel = 'urgent' | 'soon' | 'calm'
@@ -512,257 +193,12 @@ export interface MyTaskGroup {
rows: MyTaskRow[]
}
/** 담당 업무 */
export const MY_TASKS_ASSIGNED: MyTaskGroup[] = [
{
key: 'changes',
label: '수정 요청',
rows: [
{
id: 14,
title: '3분기 캠페인 예산안 재작성',
status: 'changes',
repoName: '3분기 신제품 런칭 캠페인',
metaText: '지시 · 한승우',
dueLabel: '6월 16일',
dday: 'D-1',
ddayLevel: 'urgent',
statusLabel: '수정 요청',
},
],
},
{
key: 'prog',
label: '진행 중',
rows: [
{
id: 12,
title: '브랜드 리뉴얼 킥오프 기획서 작성',
status: 'prog',
repoName: '브랜드 리뉴얼 2026',
checklist: [2, 5],
metaText: '지시 · 한승우',
dueLabel: '6월 18일',
dday: 'D-3',
ddayLevel: 'soon',
statusLabel: '진행 중',
},
{
id: 10,
title: '9월 신제품 GTM 전략 수립',
status: 'prog',
repoName: '3분기 신제품 런칭 캠페인',
checklist: [1, 4],
metaText: '지시 · 한승우',
dueLabel: '6월 21일',
dday: 'D-6',
ddayLevel: 'calm',
statusLabel: '진행 중',
},
],
},
{
key: 'todo',
label: '할 일',
rows: [
{
id: 15,
title: '월간 뉴스레터 8월호 주제 선정',
status: 'todo',
repoName: '월간 뉴스레터',
metaText: '지시 · 한승우',
dueLabel: '6월 24일',
dday: 'D-9',
ddayLevel: 'calm',
statusLabel: '할 일',
},
{
id: 13,
title: 'SEO 키워드 전략 분기 리뷰',
status: 'todo',
repoName: 'SEO 콘텐츠 허브',
metaText: '지시 · 한승우',
dueLabel: '6월 27일',
dday: 'D-12',
ddayLevel: 'calm',
statusLabel: '할 일',
},
],
},
{
key: 'review',
label: '승인 대기',
rows: [
{
id: 2,
title: '브랜드 무드보드 방향 정리',
status: 'review',
repoName: '브랜드 리뉴얼 2026',
metaText: '한승우님 검토 중 · 1일 전 요청',
dueLabel: '6월 17일',
dday: 'D-2',
ddayLevel: 'soon',
statusLabel: '승인 대기',
},
],
},
{
key: 'done',
label: '완료',
rows: [
{
id: 6,
title: '2분기 캠페인 성과 회고 정리',
status: 'done',
repoName: '월간 뉴스레터',
metaText: '지시 · 한승우',
dueLabel: '6월 11일 승인됨',
statusLabel: '완료',
done: true,
},
{
id: 5,
title: '신제품 네이밍 후보 정리',
status: 'done',
repoName: '3분기 신제품 런칭 캠페인',
metaText: '지시 · 한승우',
dueLabel: '6월 5일 승인됨',
statusLabel: '완료',
done: true,
},
],
},
]
/** 지시한 업무 */
export const MY_TASKS_ISSUED: MyTaskGroup[] = [
{
key: 'review',
label: '승인 대기',
action: '· 내 승인 필요',
attn: true,
rows: [
{
id: 9,
title: '메인 배너 이미지 2종 (가로/세로) 디자인',
status: 'review',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.park, USERS.choi],
metaText: '박지훈님이 2시간 전 승인 요청',
metaAttn: true,
dueLabel: '6월 18일',
dday: 'D-3',
ddayLevel: 'soon',
reviewBtn: true,
},
],
},
{
key: 'prog',
label: '진행 중',
rows: [
{
id: 11,
title: '법무 검토 요청 및 회신 반영',
status: 'prog',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.lee],
metaText: '이도윤',
dueLabel: '6월 12일',
dday: '3일 지남',
ddayLevel: 'urgent',
overdue: true,
statusLabel: '진행 중',
},
{
id: 16,
title: 'SNS 콘텐츠 2차 시안 제작',
status: 'prog',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.han],
metaText: '정민호',
checklist: [3, 6],
dueLabel: '6월 19일',
dday: 'D-4',
ddayLevel: 'soon',
statusLabel: '진행 중',
},
],
},
{
key: 'changes',
label: '수정 요청함',
rows: [
{
id: 8,
title: '상세페이지 카피라이팅',
status: 'changes',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.lee],
metaText: '이도윤에게 수정 요청 · 1일 전',
dueLabel: '6월 20일',
dday: 'D-5',
ddayLevel: 'soon',
statusLabel: '수정 요청',
},
],
},
{
key: 'todo',
label: '할 일',
rows: [
{
id: 7,
title: 'SNS 콘텐츠 1차 시안',
status: 'todo',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.han],
metaText: '정민호',
dueLabel: '6월 23일',
dday: 'D-8',
ddayLevel: 'calm',
statusLabel: '할 일',
},
],
},
{
key: 'done',
label: '완료',
rows: [
{
id: 6,
title: '캠페인 핵심 메시지 시안 작성',
status: 'done',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.kim],
metaText: '김서연',
dueLabel: '6월 9일 승인됨',
statusLabel: '완료',
done: true,
},
{
id: 4,
title: '퍼포먼스 광고 소재 세팅',
status: 'done',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.park],
metaText: '박지훈',
dueLabel: '6월 5일 승인됨',
statusLabel: '완료',
done: true,
},
],
},
]
/** 비완료(활성) 업무 수 — 뷰 탭 카운트용 */
/** 진행 중(완료 제외) 업무 수 — 탭 카운트 */
export function countActive(groups: MyTaskGroup[]): number {
return groups.filter((g) => g.key !== 'done').reduce((sum, g) => sum + g.rows.length, 0)
}
/* ============================================================
* 저장소 멤버
* ============================================================ */
/** 저장소 멤버 */
export interface RepoMember {
user: User
email: string
@@ -776,23 +212,6 @@ export interface RepoMember {
owner?: boolean
}
export const REPO_MEMBERS: Record<string, RepoMember[]> = {
'q3-launch-campaign': [
{ user: USERS.jung, email: 'tkjung@acme.co', taskCount: 1, roleType: 'admin', isYou: true, owner: true },
{ user: USERS.kim, email: 'sykim@acme.co', taskCount: 2, roleType: 'member' },
{ user: USERS.park, email: 'jhpark@acme.co', taskCount: 2, roleType: 'member' },
{ user: USERS.lee, email: 'dylee@acme.co', taskCount: 2, roleType: 'member' },
{ user: USERS.choi, email: 'yjchoi@acme.co', taskCount: 1, roleType: 'member' },
],
}
export function findRepoMembers(id: string): RepoMember[] {
return REPO_MEMBERS[id] ?? []
}
/* ============================================================
* 저장소 활동 피드
* ============================================================ */
/** 활동 아이콘 종류(모양) */
export type ActivityIcon =
| 'check'
@@ -809,9 +228,8 @@ export interface RepoActivityItem {
tone: 'task' | 'member' | 'setting'
/** 아이콘 모양 */
icon: ActivityIcon
/** 행위자 아바타 */
initial: string
color: AvatarColor
/** 행위자 프로필 이미지 URL — 없으면 placeholder */
avatarUrl?: string | null
/** 본문 HTML(내부 생성, 신뢰 가능 마크업) */
html: string
time: string
@@ -822,109 +240,3 @@ export interface RepoActivityDay {
day: string
items: RepoActivityItem[]
}
export const REPO_ACTIVITY: RepoActivityDay[] = [
{
day: '오늘',
items: [
{
tone: 'task',
icon: 'check',
initial: '이',
color: 'av-c',
html: '<b>이도윤</b>님이 <a class="tgt" href="#">법무 검토 요청 및 회신 반영</a> 업무를 <span class="st prog">진행 중</span>으로 변경했습니다',
time: '2시간 전',
},
{
tone: 'task',
icon: 'check',
initial: '박',
color: 'av-b',
html: '<b>박지훈</b>님이 <a class="tgt" href="#">메인 배너 이미지 2종 디자인</a>의 체크리스트 ‘가로형 메인 배너’를 완료했습니다',
time: '3시간 전',
},
{
tone: 'member',
icon: 'member-add',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 <b>한지민</b>님을 멤버로 초대했습니다',
time: '5시간 전',
},
{
tone: 'member',
icon: 'member-check',
initial: '한',
color: 'av-d',
html: '<b>한지민</b>님이 멤버로 합류했습니다',
time: '5시간 전',
},
],
},
{
day: '어제',
items: [
{
tone: 'task',
icon: 'check',
initial: '최',
color: 'av-d',
html: '<b>최유진</b>님이 <a class="tgt" href="#">메인 비주얼 컨셉 확정</a> 업무를 <span class="st done">완료</span>했습니다',
time: '어제 18:40',
},
{
tone: 'task',
icon: 'pencil',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 <a class="tgt" href="#">SNS 콘텐츠 1차 시안</a> 업무를 지시했습니다 <span style="color:var(--text-3)">· 담당 정민호</span>',
time: '어제 14:10',
},
],
},
{
day: '이번 주',
items: [
{
tone: 'setting',
icon: 'lock',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 저장소 공개 범위를 <b>비공개</b>로 변경했습니다',
time: '6월 11일',
},
{
tone: 'member',
icon: 'member-remove',
initial: '박',
color: 'av-b',
html: '<b>박지훈</b>님의 역할이 <b>멤버</b>로 설정되었습니다',
time: '6월 11일',
},
{
tone: 'setting',
icon: 'pencil',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 표시 제목을 ‘3분기 신제품 런칭 캠페인’으로 변경했습니다',
time: '6월 10일',
},
{
tone: 'task',
icon: 'check',
initial: '김',
color: 'av-a',
html: '<b>김서연</b>님이 <a class="tgt" href="#">캠페인 핵심 메시지 시안 작성</a> 업무를 <span class="st done">완료</span>했습니다',
time: '6월 9일',
},
{
tone: 'setting',
icon: 'repo',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 저장소를 생성했습니다',
time: '6월 2일',
},
],
},
]
+9 -22
View File
@@ -3,6 +3,7 @@
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useMyTaskStore } from '@/stores/my-task.store'
import { countActive, type MyTaskRow } from '@/mock/relay.mock'
@@ -258,8 +259,10 @@ function loadMore() {
v-for="a in row.assignees"
:key="a.id"
class="mini-av"
:class="a.color"
>{{ a.initial }}</span>
><UserAvatar
:url="a.avatarUrl"
:name="a.name"
/></span>
</span>
{{ row.metaText }}
</span>
@@ -271,8 +274,10 @@ function loadMore() {
v-for="a in row.assignees"
:key="a.id"
class="mini-av"
:class="a.color"
>{{ a.initial }}</span>
><UserAvatar
:url="a.avatarUrl"
:name="a.name"
/></span>
</span>
</span>
<span class="meta-sep" />
@@ -676,24 +681,6 @@ function loadMore() {
box-shadow: 0 0 0 1.5px #fff;
flex-shrink: 0;
}
.mini-av.av-a {
background: #e0518d;
}
.mini-av.av-b {
background: #3d8bf2;
}
.mini-av.av-c {
background: #16a37b;
}
.mini-av.av-d {
background: #b3631f;
}
.mini-av.av-e {
background: #7c5cf0;
}
.mini-av.av-f {
background: #d0982a;
}
.mini-stack .mini-av + .mini-av {
margin-left: -0.3125rem;
}
+2 -22
View File
@@ -5,6 +5,7 @@ import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import RepoHeader from '@/components/RepoHeader.vue'
import RepoTabs from '@/components/RepoTabs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRepoStore } from '@/stores/repo.store'
import { useActivityStore } from '@/stores/activity.store'
import type { Repo, ActivityIcon } from '@/mock/relay.mock'
@@ -209,10 +210,7 @@ const ActIcon = defineComponent({
:class="item.tone"
><ActIcon :name="item.icon" /></span>
<div class="act-main">
<span
class="act-av"
:class="item.color"
>{{ item.initial }}</span>
<span class="act-av"><UserAvatar :url="item.avatarUrl" /></span>
<span
class="act-text"
v-html="item.html"
@@ -341,24 +339,6 @@ const ActIcon = defineComponent({
color: #fff;
flex-shrink: 0;
}
.act-av.av-a {
background: #e0518d;
}
.act-av.av-b {
background: #3d8bf2;
}
.act-av.av-c {
background: #16a37b;
}
.act-av.av-d {
background: #b3631f;
}
.act-av.av-e {
background: #7c5cf0;
}
.act-av.av-f {
background: #d0982a;
}
.act-text {
font-size: 0.844rem;
color: var(--text-2);
+1 -2
View File
@@ -5,7 +5,6 @@ import { storeToRefs } from 'pinia'
import { useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { useRepoStore } from '@/stores/repo.store'
import { initialOf } from '@/shared/utils/format'
import type { Visibility } from '@/mock/relay.mock'
const router = useRouter()
@@ -13,7 +12,7 @@ const repoStore = useRepoStore()
// 생성 폼 메타 — 소유자(GITEA_ORG) 고정 표시 + 템플릿 가용 정보
const { owner, templateAvailable, templateSlug } = storeToRefs(repoStore)
const ownerInitial = computed(() => initialOf(owner.value || '?').toUpperCase())
const ownerInitial = computed(() => (owner.value.trim().charAt(0) || '?').toUpperCase())
onMounted(() => {
void repoStore.fetchCreateMeta()
})
+5 -2
View File
@@ -8,6 +8,7 @@ import AppShell from '@/layouts/AppShell.vue'
import RepoHeader from '@/components/RepoHeader.vue'
import RepoTabs from '@/components/RepoTabs.vue'
import GiteaRepoBar from '@/components/GiteaRepoBar.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRepoStore } from '@/stores/repo.store'
import { useTaskStore } from '@/stores/task.store'
import type { TaskListQuery, TaskSegment } from '@/types/task'
@@ -378,8 +379,10 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
v-for="a in task.assignees"
:key="a.id"
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
><UserAvatar
:url="a.avatarUrl"
:name="a.name"
/></span>
</span>
<span
class="st-badge"
+5 -2
View File
@@ -4,6 +4,7 @@ import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRepoStore } from '@/stores/repo.store'
import type { Visibility } from '@/mock/relay.mock'
@@ -265,8 +266,10 @@ const filteredRepos = computed(() =>
v-for="m in repo.members"
:key="m.id"
class="avatar"
:class="m.color"
>{{ m.initial }}</span>
><UserAvatar
:url="m.avatarUrl"
:name="m.name"
/></span>
<span
v-if="repo.moreCount > 0"
class="avatar av-more"
+9 -27
View File
@@ -5,10 +5,10 @@ import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import RepoHeader from '@/components/RepoHeader.vue'
import RepoTabs from '@/components/RepoTabs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRepoStore } from '@/stores/repo.store'
import { useMemberStore } from '@/stores/member.store'
import { useUser } from '@/composables/useUser'
import { avatarColor, initialOf } from '@/shared/utils/format'
import type { Repo } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
import type { MemberRoleType } from '@/types/member'
@@ -339,10 +339,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
:key="m.user.id"
class="mem-row"
>
<span
class="mem-av"
:class="m.user.color"
>{{ m.user.initial }}</span>
<span class="mem-av"><UserAvatar
:url="m.user.avatarUrl"
:name="m.user.name"
/></span>
<div class="mem-info">
<div class="mem-name">
{{ m.user.name }} <span
@@ -522,10 +522,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span
class="avatar user-av"
:class="avatarColor(u.id)"
>{{ initialOf(u.name) }}</span>
<span class="avatar user-av"><UserAvatar
:url="u.avatarUrl"
:name="u.name"
/></span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
@@ -659,24 +659,6 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
color: #fff;
flex-shrink: 0;
}
.mem-av.av-a {
background: #e0518d;
}
.mem-av.av-b {
background: #3d8bf2;
}
.mem-av.av-c {
background: #16a37b;
}
.mem-av.av-d {
background: #b3631f;
}
.mem-av.av-e {
background: #7c5cf0;
}
.mem-av.av-f {
background: #d0982a;
}
.mem-info {
flex: 1;
min-width: 0;
+15 -17
View File
@@ -4,12 +4,12 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRepoStore } from '@/stores/repo.store'
import { useMemberStore } from '@/stores/member.store'
import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { useTask } from '@/composables/useTask'
import { avatarColor, initialOf } from '@/shared/utils/format'
import type { Repo, ChecklistItem, User } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
@@ -32,8 +32,7 @@ function toUserView(m: ApiMember): User {
id: m.id,
name: m.name,
role: m.role ?? '',
initial: initialOf(m.name),
color: avatarColor(m.id),
avatarUrl: m.avatarUrl,
}
}
@@ -100,8 +99,7 @@ const issuer = computed<User>(() => {
id: u?.id ?? '',
name: u?.name ?? '나',
role: u?.role ?? '',
initial: initialOf(u?.name ?? '나'),
color: avatarColor(u?.id ?? ''),
avatarUrl: u?.avatarUrl ?? null,
}
})
@@ -470,10 +468,10 @@ function goBack() {
class="assignee-dd-item"
@mousedown.prevent="addAssignee(m.user.id)"
>
<span
class="avatar"
:class="m.user.color"
>{{ m.user.initial }}</span>
<span class="avatar"><UserAvatar
:url="m.user.avatarUrl"
:name="m.user.name"
/></span>
<span class="ad-nm">{{ m.user.name }}</span>
<span class="ad-em">{{ m.email }}</span>
</button>
@@ -495,10 +493,10 @@ function goBack() {
:key="a.id"
class="chip"
>
<span
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
<span class="avatar"><UserAvatar
:url="a.avatarUrl"
:name="a.name"
/></span>
{{ a.name }}
<span
class="x"
@@ -537,10 +535,10 @@ function goBack() {
지시자
</div>
<div class="issuer">
<span
class="avatar"
:class="issuer.color"
>{{ issuer.initial }}</span>
<span class="avatar"><UserAvatar
:url="issuer.avatarUrl"
:name="issuer.name"
/></span>
<span>
<div class="nm">{{ issuer.name }}</div>
</span>
+32 -29
View File
@@ -23,7 +23,8 @@ import {
} from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { attachmentKindOf, avatarColor, fileBadge, initialOf } from '@/shared/utils/format'
import UserAvatar from '@/components/UserAvatar.vue'
import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
const route = useRoute()
const router = useRouter()
@@ -188,8 +189,7 @@ const me = computed<User>(() => {
id: u?.id ?? '',
name: u?.name ?? '나',
role: u?.role ?? '',
initial: initialOf(u?.name ?? '나'),
color: avatarColor(u?.id ?? ''),
avatarUrl: u?.avatarUrl ?? null,
}
})
@@ -769,10 +769,10 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
:key="i"
class="comment"
>
<span
class="c-av"
:class="c.author.color"
>{{ c.author.initial }}</span>
<span class="c-av"><UserAvatar
:url="c.author.avatarUrl"
:name="c.author.name"
/></span>
<div class="c-body">
<div class="c-head">
<span class="c-name">{{ c.author.name }}</span>
@@ -804,10 +804,10 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
:key="ri"
class="c-reply"
>
<span
class="c-av"
:class="r.author.color"
>{{ r.author.initial }}</span>
<span class="c-av"><UserAvatar
:url="r.author.avatarUrl"
:name="r.author.name"
/></span>
<div class="c-body">
<div class="c-head">
<span class="c-name">{{ r.author.name }}</span><span class="c-time">{{ r.time }}</span>
@@ -841,10 +841,10 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
class="reply-form"
:class="{ open: replyOpen === i }"
>
<span
class="c-av"
:class="me.color"
>{{ me.initial }}</span>
<span class="c-av"><UserAvatar
:url="me.avatarUrl"
:name="me.name"
/></span>
<div class="field">
<textarea
v-model="replyText"
@@ -872,10 +872,10 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
</div>
<div class="composer">
<span
class="c-av"
style="background: #0ea5a3"
></span>
<span class="c-av"><UserAvatar
:url="me.avatarUrl"
:name="me.name"
/></span>
<div class="field">
<textarea
v-model="commentText"
@@ -1217,10 +1217,10 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
:key="a.id"
class="ass"
>
<span
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
<span class="avatar"><UserAvatar
:url="a.avatarUrl"
:name="a.name"
/></span>
<span><div class="nm">{{ a.name }}</div><div class="rl">{{ a.role }}</div></span>
</div>
</div>
@@ -1335,10 +1335,10 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
지시자
</div>
<div class="issuer">
<span
class="avatar"
:class="task.issuer.color"
>{{ task.issuer.initial }}</span>
<span class="avatar"><UserAvatar
:url="task.issuer.avatarUrl"
:name="task.issuer.name"
/></span>
<span><div class="nm">{{ task.issuer.name }}</div><div class="rl">{{ task.issuer.role }}</div></span>
</div>
<div class="meta-line">
@@ -1606,7 +1606,10 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
v-else-if="item.type === 'user'"
class="ag-msg user"
>
<span class="ava"></span>
<span class="ava"><UserAvatar
:url="me.avatarUrl"
:name="me.name"
/></span>
<span class="bubble">{{ item.text }}</span>
</div>
@@ -3121,7 +3124,7 @@ a.file-info {
}
.ag-msg.user .ava {
order: 2;
background: #0ea5a3;
/* 내부 UserAvatar 가 이미지/placeholder 로 배경을 채운다 */
color: #fff;
border-radius: 50%;
}
-19
View File
@@ -26,25 +26,6 @@ export function formatDate(input: Date | string): string {
* (서버는 타임스탬프/카운트만 내려주고 여기서 계산한다)
* ============================================================ */
/** 아바타 색상 키 (relay.css 의 .av-* 클래스와 대응) */
export type AvatarColorKey = 'av-a' | 'av-b' | 'av-c' | 'av-d' | 'av-e' | 'av-f'
const AVATAR_COLORS: AvatarColorKey[] = ['av-a', 'av-b', 'av-c', 'av-d', 'av-e', 'av-f']
/** 식별자(문자열)를 결정적으로 아바타 색상에 매핑 */
export function avatarColor(seed: string): AvatarColorKey {
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
}
return AVATAR_COLORS[hash % AVATAR_COLORS.length] ?? 'av-a'
}
/** 이름의 첫 글자(아바타 이니셜) */
export function initialOf(name: string): string {
return name.trim().charAt(0) || '?'
}
/** ISO 문자열/Date 를 한국어 상대시간으로 (예: "2시간 전", "어제", "3일 전") */
export function relativeTimeKo(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input
+4 -5
View File
@@ -2,10 +2,8 @@ import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useActivity } from '@/composables/useActivity'
import {
avatarColor,
dayGroupKo,
escapeHtml,
initialOf,
monthDayKo,
relativeTimeKo,
taskStatusLabel,
@@ -24,10 +22,11 @@ function pstr(payload: Record<string, unknown>, key: string): string {
}
// 행위자/시간 등 공통 필드
function baseOf(api: ApiActivity): Pick<RepoActivityItem, 'initial' | 'color' | 'time'> {
function baseOf(
api: ApiActivity,
): Pick<RepoActivityItem, 'avatarUrl' | 'time'> {
return {
initial: initialOf(api.actor?.name ?? '?'),
color: avatarColor(api.actor?.id ?? ''),
avatarUrl: api.actor?.avatarUrl ?? null,
time: relativeTimeKo(api.createdAt),
}
}
+1 -3
View File
@@ -2,7 +2,6 @@ import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useMember } from '@/composables/useMember'
import { useAuthStore } from '@/stores/auth.store'
import { avatarColor, initialOf } from '@/shared/utils/format'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import type {
ApiRepoMember,
@@ -18,8 +17,7 @@ function toMemberView(api: ApiRepoMember, currentUserId: string | null): MemberV
id: api.user.id,
name: api.user.name,
role: api.user.role ?? '',
initial: initialOf(api.user.name),
color: avatarColor(api.user.id),
avatarUrl: api.user.avatarUrl,
},
email: api.email,
taskCount: api.taskCount,
+2 -3
View File
@@ -2,7 +2,7 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useMyTask } from '@/composables/useMyTask'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import { avatarColor, initialOf, taskDueInfo, taskStatusLabel } from '@/shared/utils/format'
import { taskDueInfo, taskStatusLabel } from '@/shared/utils/format'
import type { ApiMember } from '@/types/repo'
import type { ApiMyTaskRow } from '@/types/my-task'
import type {
@@ -18,8 +18,7 @@ function toUserView(m: ApiMember): UserView {
id: m.id,
name: m.name,
role: m.role ?? '',
initial: initialOf(m.name),
color: avatarColor(m.id),
avatarUrl: m.avatarUrl,
}
}
+2 -3
View File
@@ -2,7 +2,7 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useRepo } from '@/composables/useRepo'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import { avatarColor, initialOf, progressOf, relativeTimeKo } from '@/shared/utils/format'
import { progressOf, relativeTimeKo } from '@/shared/utils/format'
import type { ApiMember, ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo'
import type { Repo as RepoView, User as UserView } from '@/mock/relay.mock'
@@ -15,8 +15,7 @@ function toUserView(member: ApiMember): UserView {
id: member.id,
name: member.name,
role: member.role ?? '',
initial: initialOf(member.name),
color: avatarColor(member.id),
avatarUrl: member.avatarUrl,
}
}
+1 -6
View File
@@ -3,10 +3,8 @@ import { defineStore } from 'pinia'
import { useTask } from '@/composables/useTask'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import {
avatarColor,
escapeHtml,
formatBytes,
initialOf,
monthDayKo,
relativeTimeKo,
taskDueInfo,
@@ -42,8 +40,7 @@ function toUserView(m: ApiMember): UserView {
id: m.id,
name: m.name,
role: m.role ?? '',
initial: initialOf(m.name),
color: avatarColor(m.id),
avatarUrl: m.avatarUrl,
}
}
@@ -52,8 +49,6 @@ const UNKNOWN_USER: UserView = {
id: '',
name: '알 수 없음',
role: '',
initial: '?',
color: 'av-a',
}
// API 업무 목록 행 → 화면용 RepoTask
+1
View File
@@ -4,6 +4,7 @@ export interface AuthUser {
email: string
name: string
role: string | null
avatarUrl: string | null
}
// 로그인 요청 페이로드
+1
View File
@@ -6,6 +6,7 @@ export interface ApiMember {
email: string
name: string
role: string | null
avatarUrl: string | null
}
// 저장소(API 원시) — 백엔드 RepoResponse 와 1:1