Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/entities/meeting-note/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export {
toMeetingNoteUpdate,
toMeetingDate,
toMeetingAt,
getParticipantColor,
MEETING_NOTE_SELECT_QUERY,
type MeetingNoteQueryRow,
} from './model/meeting-note.mapper';
Expand Down
23 changes: 2 additions & 21 deletions src/entities/meeting-note/model/meeting-note.mapper.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getAvatarColor } from '@/shared/lib/avatar-color';
import type { GenericTablesInsert, GenericTablesUpdate } from '@/shared/model/supabase.types';

import type { MeetingNoteRow } from './meeting-note.db.types';
Expand All @@ -19,28 +20,8 @@ export type MeetingNoteQueryRow = Pick<
export const MEETING_NOTE_SELECT_QUERY =
'id, workspace_id, author_id, title, meeting_at, participants, decisions, follow_up_actions';

// 참석자 아바타 색상은 DB에 저장하지 않고 userId 해시로 결정론적으로 재생성한다.
// 같은 참석자는 어느 카드에서든 항상 같은 색으로 보인다.
const PARTICIPANT_PALETTE = [
'#FE9A00',
'#00C950',
'#615FFF',
'#2B7FFF',
'#00B8DB',
'#FF6B6B',
] as const;

const KST_TIME_ZONE = 'Asia/Seoul';

export function getParticipantColor(seed: string): string {
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}

return PARTICIPANT_PALETTE[hash % PARTICIPANT_PALETTE.length];
}

// DB의 meeting_at(timestamptz) → UI 표기용 KST 날짜(YYYY-MM-DD)
export function toMeetingDate(meetingAt: string): string {
const parts = new Intl.DateTimeFormat('en-CA', {
Expand Down Expand Up @@ -73,7 +54,7 @@ function toParticipant(userId: string, profileNameById: Map<string, string>): Me
id: userId,
name,
initial: name.slice(0, 1),
color: getParticipantColor(userId),
color: getAvatarColor(userId),
};
}

Expand Down
23 changes: 3 additions & 20 deletions src/entities/task/model/task.mapper.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,9 @@
import { getAvatarColor } from '@/shared/lib/avatar-color';
import type { GenericTablesInsert, GenericTablesUpdate } from '@/shared/model/supabase.types';

import type { TaskRow, TaskStatusDb } from './task.db.types';
import type { Task, TaskStatus } from './task.types';

const AVATAR_PALETTE = [
'#00C950',
'#FE9A00',
'#615FFF',
'#00B8DB',
'#2B7FFF',
'#F6339A',
'#7E22CE',
] as const;

export interface TaskQueryRow extends Pick<
TaskRow,
'id' | 'workspace_id' | 'title' | 'assignee_id' | 'due_date' | 'status' | 'sort_order'
Expand Down Expand Up @@ -52,15 +43,6 @@ function formatDueDate(isoDate: string | null) {
return `${Number(month)}/${Number(day)}`;
}

function getAvatarColor(seed: string) {
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}

return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
}

export function toTask(row: TaskQueryRow): Task {
const assigneeName = row.assignee_profile?.real_name ?? '미배정';

Expand All @@ -71,7 +53,8 @@ export function toTask(row: TaskQueryRow): Task {
assigneeId: row.assignee_id,
assignee: assigneeName,
assigneeInitial: assigneeName.slice(0, 1),
assigneeColor: getAvatarColor(assigneeName),
// 색상은 userId(assignee_id) 기준 — 미배정이면 shared 유틸이 중립색을 돌려준다.
assigneeColor: getAvatarColor(row.assignee_id),
dueDate: formatDueDate(row.due_date),
status: toUiTaskStatus(row.status),
sortOrder: row.sort_order,
Expand Down
13 changes: 10 additions & 3 deletions src/features/manage-chat/ui/ChatMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// 메시지를 발신자 기준 말풍선으로 렌더링하고 새 메시지 도착 시 하단으로 스크롤합니다.
import { useEffect, useRef } from 'react';
import type { ChatMessage } from '@/entities/chat';
import { getAvatarColor } from '@/shared/lib/avatar-color';
import { cn } from '@/shared/lib/utils';

interface ChatMessageListProps {
Expand Down Expand Up @@ -83,7 +84,10 @@ export function ChatMessageList({ messages, viewerId, isLoading }: ChatMessageLi
className={cn('flex items-end gap-3', isOwnMessage ? 'justify-end' : 'justify-start')}
>
{!isOwnMessage ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-sm font-bold text-white">
<div
style={{ backgroundColor: getAvatarColor(message.senderId) }}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
>
{getAvatarLabel(message.senderName)}
</div>
) : null}
Expand All @@ -99,7 +103,7 @@ export function ChatMessageList({ messages, viewerId, isLoading }: ChatMessageLi
</div>
<p
className={cn(
'w-fit rounded-2xl px-4 py-3 text-sm leading-6 whitespace-pre-wrap',
'w-fit rounded-2xl px-4 py-3 text-sm leading-6 whitespace-pre-wrap break-all',
isOwnMessage
? 'ml-auto rounded-br-md bg-[var(--color-brand)] text-white'
: 'rounded-bl-md bg-slate-100 text-slate-800',
Expand All @@ -109,7 +113,10 @@ export function ChatMessageList({ messages, viewerId, isLoading }: ChatMessageLi
</p>
</div>
{isOwnMessage ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-400 text-sm font-bold text-white">
<div
style={{ backgroundColor: getAvatarColor(message.senderId) }}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
>
{getAvatarLabel(message.senderName)}
</div>
) : null}
Expand Down
6 changes: 5 additions & 1 deletion src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { toast } from 'sonner';
import type { MeetingNote, MeetingNoteFormValues } from '@/entities/meeting-note';
import { createMeetingNote, meetingNotesQueryKey, updateMeetingNote } from '@/entities/meeting-note';
import { useWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member';
import { getAvatarColor } from '@/shared/lib/avatar-color';

interface MeetingNoteFormProps {
workspaceId: string;
Expand Down Expand Up @@ -425,7 +426,10 @@ export function MeetingNoteForm({ workspaceId, meetingNote }: MeetingNoteFormPro
className="hover:bg-brand-soft flex w-full items-center justify-between rounded-[14px] px-3 py-3 text-left transition"
>
<div className="flex items-center gap-3">
<div className="bg-brand flex size-9 items-center justify-center rounded-full text-[15px] font-semibold text-white">
<div
style={{ backgroundColor: getAvatarColor(member.userId) }}
className="flex size-9 items-center justify-center rounded-full text-[15px] font-semibold text-white"
>
{member.avatarLabel}
</div>
<div>
Expand Down
19 changes: 0 additions & 19 deletions src/features/manage-sprint-tasks/lib/avatar-color.ts

This file was deleted.

5 changes: 3 additions & 2 deletions src/features/manage-sprint-tasks/ui/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { Pencil, Trash2 } from 'lucide-react';

import { TASK_CATEGORY } from '@/entities/side-project/task';

import { getAvatarColor } from '@/shared/lib/avatar-color';

import type { BoardTask } from '../model/board-task';
import { getAvatarColor } from '../lib/avatar-color';

interface TaskCardProps {
task: BoardTask;
Expand Down Expand Up @@ -59,7 +60,7 @@ export function TaskCard({
<div className="mt-3 flex items-center gap-2">
<span
className="flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: getAvatarColor(task.assignee.avatarLabel) }}
style={{ backgroundColor: getAvatarColor(task.assigneeId) }}
>
{task.assignee.avatarLabel}
</span>
Expand Down
4 changes: 2 additions & 2 deletions src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import type { WorkspaceMember } from '@/entities/workspace-member';
import { cn } from '@/shared/lib/utils';

import { getAvatarColor } from '../lib/avatar-color';
import { getAvatarColor } from '@/shared/lib/avatar-color';
import { EMPTY_TASK_FORM, type TaskFormValues } from '../model/task-form';

const inputClass =
Expand Down Expand Up @@ -181,7 +181,7 @@ export function TaskFormDialog({
>
<span
className="flex size-5 items-center justify-center rounded-full text-[10px] font-semibold text-white"
style={{ backgroundColor: getAvatarColor(member.avatarLabel) }}
style={{ backgroundColor: getAvatarColor(member.userId) }}
>
{member.avatarLabel}
</span>
Expand Down
9 changes: 7 additions & 2 deletions src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
updateWorkShiftType,
} from '@/entities/work-schedule/api/work-schedule-actions';
import type { WorkspaceMember } from '@/entities/workspace-member';
import { getAvatarColor } from '@/shared/lib/avatar-color';
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -218,7 +219,10 @@ export function WorkScheduleBoard({
className="grid grid-cols-[150px_repeat(7,minmax(96px,1fr))] border-b border-slate-100"
>
<div className="flex min-w-0 items-center gap-3 px-5 py-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-400 text-sm font-bold text-white">
<span
style={{ backgroundColor: getAvatarColor(member.userId) }}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
>
{member.avatarLabel}
</span>
<span className="font-semibold break-keep whitespace-nowrap text-slate-900">
Expand Down Expand Up @@ -284,7 +288,8 @@ export function WorkScheduleBoard({
{workMembers.map((member) => (
<span
key={member.userId}
className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-500 text-xs font-bold text-white"
style={{ backgroundColor: getAvatarColor(member.userId) }}
className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold text-white"
>
{member.avatarLabel}
</span>
Expand Down
19 changes: 4 additions & 15 deletions src/features/manage-workspace-members/ui/MemberList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
WORKSPACE_MEMBER_STATUS_META,
type WorkspaceMember,
} from '@/entities/workspace-member';
import { getAvatarColor } from '@/shared/lib/avatar-color';
import { Badge } from '@/shared/ui/badge';
import { cn } from '@/shared/lib/utils';

Expand All @@ -14,16 +15,6 @@ interface MemberListProps {
currentUserId: string;
}

// 아바타 배경 색상 팔레트 — 멤버 순서에 따라 순환 배정한다.
const AVATAR_COLORS = [
'bg-orange-400',
'bg-indigo-400',
'bg-emerald-400',
'bg-rose-400',
'bg-sky-400',
'bg-amber-400',
];

export function MemberList({ members, currentUserId }: MemberListProps) {
return (
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
Expand All @@ -35,7 +26,7 @@ export function MemberList({ members, currentUserId }: MemberListProps) {
</p>
) : (
<ul className="mt-4 space-y-3">
{members.map((member, index) => {
{members.map((member) => {
const roleMeta = WORKSPACE_MEMBER_ROLE_META[member.role];
const statusMeta = WORKSPACE_MEMBER_STATUS_META[member.status];
const isCurrentUser = member.userId === currentUserId;
Expand All @@ -49,10 +40,8 @@ export function MemberList({ members, currentUserId }: MemberListProps) {
)}
>
<span
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white',
AVATAR_COLORS[index % AVATAR_COLORS.length],
)}
style={{ backgroundColor: getAvatarColor(member.userId) }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
aria-hidden="true"
>
{member.avatarLabel}
Expand Down
26 changes: 26 additions & 0 deletions src/shared/lib/avatar-color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 사용자별 아바타 배경색 — seed(userId 권장) 해시로 결정론적 색을 고른다.
// 같은 사용자는 어느 화면에서든 항상 같은 색이 나오고, seed가 없으면(미배정 등) 중립 회색을 반환한다.
const AVATAR_PALETTE = [
'#FE9A00',
'#00C950',
'#615FFF',
'#2B7FFF',
'#00B8DB',
'#FF6B6B',
] as const;

// 담당자 미배정·미상일 때 사용하는 중립색
export const UNASSIGNED_AVATAR_COLOR = '#CBD5E1';

export function getAvatarColor(seed: string | null | undefined): string {
if (!seed) {
return UNASSIGNED_AVATAR_COLOR;
}

let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}

return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
}
6 changes: 5 additions & 1 deletion src/views/chat/ui/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { MessageCircleMore, UsersRound } from 'lucide-react';
import type { ChatRoomData } from '@/entities/chat';
import { ChatComposer, ChatMessageList, useChatRoom } from '@/features/manage-chat';
import { getAvatarColor } from '@/shared/lib/avatar-color';

interface ChatViewProps {
workspaceId: string;
Expand Down Expand Up @@ -93,7 +94,10 @@ export function ChatView({ workspaceId, initialData }: ChatViewProps) {
<div className="mt-4 space-y-3">
{participants.slice(0, 8).map((participant) => (
<div key={participant.userId} className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-sm font-bold text-slate-600">
<div
style={{ backgroundColor: getAvatarColor(participant.userId) }}
className="flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold text-white"
>
{getAvatarLabel(participant.name)}
</div>
<p className="truncate text-sm font-medium text-slate-700">{participant.name}</p>
Expand Down
4 changes: 3 additions & 1 deletion src/widgets/workspace-shell/ui/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react';
import type { WorkspaceMember } from '@/entities/workspace-member';
import { WorkspaceSearchPanel } from '@/features/workspace-search';
import { NotificationPanel } from '@/features/workspace-notifications';
import { getAvatarColor } from '@/shared/lib/avatar-color';
import { useLogout } from '@/shared/lib/use-logout';
import { cn } from '@/shared/lib/utils';
import type { WorkspaceNavigationItem } from '../model/workspace-navigation';
Expand Down Expand Up @@ -117,7 +118,8 @@ export function WorkspaceHeader({
setIsSearchOpen(false);
setIsNotificationOpen(false);
}}
className="flex h-10 w-10 items-center justify-center rounded-full bg-orange-400 text-sm font-bold text-white hover:opacity-90"
style={{ backgroundColor: getAvatarColor(currentMember.userId) }}
className="flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold text-white hover:opacity-90"
>
{currentMember.avatarLabel}
</button>
Expand Down
6 changes: 5 additions & 1 deletion src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ChevronRight, LogOut, Menu } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { WORKSPACE_PURPOSE_META, type Workspace } from '@/entities/workspace';
import type { WorkspaceMember } from '@/entities/workspace-member';
import { getAvatarColor } from '@/shared/lib/avatar-color';
import { useLogout } from '@/shared/lib/use-logout';
import { cn } from '@/shared/lib/utils';
import type { WorkspaceNavigationItem } from '../model/workspace-navigation';
Expand Down Expand Up @@ -127,7 +128,10 @@ export function WorkspaceSidebar({
)}
>
<div className={cn('flex min-w-0 items-center', !isCollapsed && 'gap-3')}>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-400 text-sm font-bold text-white">
<span
style={{ backgroundColor: getAvatarColor(currentMember.userId) }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
>
{currentMember.avatarLabel}
</span>
<span className={cn('min-w-0', isCollapsed && 'sr-only')}>
Expand Down