Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
92c1a76
fix: 429 텀 15초로 변경
ShipFriend0516 Apr 17, 2026
fe84a07
refactor: 글자 수 제한 및 수정됨 표시 버그 수정
ShipFriend0516 Apr 17, 2026
c68a0af
fix: 200자 제한시 테두리 변경처리
ShipFriend0516 Apr 18, 2026
2f343a9
feat: 마크다운 미리보기 기능 구현
ShipFriend0516 Apr 18, 2026
a926c6d
fix: 정방향으로 렌더링하고 스크롤 방식이 아닌 flex reverse로 렌더링
ShipFriend0516 Apr 18, 2026
57c4668
feat: 폴링, 새로고침 할 때 가져오는 동안 로딩스피너 추가
ShipFriend0516 Apr 18, 2026
38dfed1
feat: 댓글 삭제 낙관적업데이트
ShipFriend0516 Apr 18, 2026
071a155
refactor: delete modal 에서 modal 공통으로 분리하고, 유틸컴포넌트로 변경
ShipFriend0516 Apr 18, 2026
ce5c716
feat: 삭제 애니메이션 추가
ShipFriend0516 Apr 18, 2026
43778de
feat: 모달에 단축키 추가
ShipFriend0516 Apr 18, 2026
fe15f2a
feat: 마크다운 링크 새창에서 열리게 변경
ShipFriend0516 Apr 18, 2026
f8cc4e7
fix: github 로그인만 허용
ShipFriend0516 Apr 18, 2026
5d51aeb
chore: 주석처리
ShipFriend0516 Apr 18, 2026
e09cba6
fix: lint
ShipFriend0516 Apr 18, 2026
d33538b
feat: 이모지 누른 사람이 누구인지 기록
ShipFriend0516 Apr 18, 2026
0423438
feat: 이모지 툴바 열리는 방식 변경
ShipFriend0516 Apr 18, 2026
a1a8f96
feat: toast가 스택형식으로 쌓이게 변경
ShipFriend0516 Apr 18, 2026
dff641a
feat: nav sidebar 구현
ShipFriend0516 Apr 18, 2026
830a314
refactor: useSubscribe 사용하도록 footer 수정
ShipFriend0516 Apr 18, 2026
3a6d4e8
feat: navbar 모바일에서는 아이콘버튼만
ShipFriend0516 Apr 18, 2026
4689489
refactor: divider 분리
ShipFriend0516 Apr 18, 2026
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
35 changes: 32 additions & 3 deletions app/api/atelier/messages/[id]/reaction/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ export const POST = async (request: Request, { params }: RouteParams) => {
{ status: 400 }
);
}
const { emoji } = body as { emoji: string };
const { emoji, nickname, avatarUrl, githubId } = body as {
emoji: string;
nickname?: string;
avatarUrl?: string;
githubId?: string;
};
if (!allowedEmojiSet.has(emoji)) {
return Response.json(
{ success: false, error: '허용되지 않은 이모지입니다.' },
Expand All @@ -69,48 +74,72 @@ export const POST = async (request: Request, { params }: RouteParams) => {
}

// 해당 이모지 버킷 찾기
interface ReactorDoc {
fingerprint: string;
nickname: string;
avatarUrl?: string;
githubId?: string;
}
interface ReactionDoc {
emoji: string;
fingerprints: string[];
count: number;
reactors: ReactorDoc[];
}
const reactions = message.reactions as unknown as ReactionDoc[];
const bucketIndex = reactions.findIndex((r) => r.emoji === emoji);

const reactorInfo: ReactorDoc = {
fingerprint,
nickname: nickname ?? '익명',
...(avatarUrl && { avatarUrl }),
...(githubId && { githubId }),
};

if (bucketIndex === -1) {
// 새 버킷 추가
reactions.push({
emoji,
fingerprints: [fingerprint],
count: 1,
reactors: [reactorInfo],
});
} else {
const bucket = reactions[bucketIndex];
const fpIndex = bucket.fingerprints.indexOf(fingerprint);
if (fpIndex >= 0) {
// 이미 눌렀음 → 해제
bucket.fingerprints.splice(fpIndex, 1);
bucket.reactors = bucket.reactors.filter((r) => r.fingerprint !== fingerprint);
bucket.count = bucket.fingerprints.length;
if (bucket.count === 0) {
reactions.splice(bucketIndex, 1);
}
} else {
// 새로 추가
bucket.fingerprints.push(fingerprint);
bucket.reactors.push(reactorInfo);
bucket.count = bucket.fingerprints.length;
}
}

message.markModified('reactions');
await message.save();

// 응답 시 fingerprints 제거, hasReacted 계산
// 응답 시 fingerprints 제거, hasReacted + reactors 계산
let anonCounter = 0;
const responseReactions: ReactionBucket[] = (
message.reactions as unknown as ReactionDoc[]
).map((r) => ({
emoji: r.emoji,
count: r.count,
hasReacted: r.fingerprints.includes(fingerprint),
reactors: r.reactors.map((reactor) => {
if (reactor.githubId) {
return { displayName: reactor.nickname, avatarUrl: reactor.avatarUrl };
}
anonCounter += 1;
return { displayName: `익명${anonCounter}` };
}),
}));

return Response.json(
Expand Down
61 changes: 38 additions & 23 deletions app/atelier/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { Cormorant_Garamond } from 'next/font/google';
import Image from 'next/image';
import { useState } from 'react';
import { createPortal } from 'react-dom';
import ChatFeed from '@/app/entities/atelier/ChatFeed';
import MessageInput from '@/app/entities/atelier/MessageInput';
import NicknameGate from '@/app/entities/atelier/NicknameGate';
Expand Down Expand Up @@ -32,6 +33,8 @@ const AtelierPage = () => {
removeOptimistic,
updateMessage,
removeMessage,
getSnapshot,
restoreMessage,
} = useAtelierMessages({ limit: 30 });

const mutations = useAtelierMutations({
Expand All @@ -48,6 +51,8 @@ const AtelierPage = () => {
removeOptimistic,
updateMessage,
removeMessage,
getSnapshot,
restoreMessage,
},
});

Expand Down Expand Up @@ -131,23 +136,31 @@ const AtelierPage = () => {
<section className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-[calc(100dvh-4rem)] flex flex-col gap-4 py-4">
<div className="flex flex-col gap-4 flex-1 min-h-0 animate-atelierIn duration-1000">
{/* 헤더 */}
<div className="shrink-0">
<h1
className={`${cormorant.className} text-[clamp(2rem,8vw,3.5rem)] font-light italic tracking-wide relative inline-block`}
>
Atelier
<Image
src="/images/atelier/pen.png"
alt="pen"
width={1024}
height={1024}
priority
className="absolute -right-[clamp(3rem,8vw,6.5rem)] top-1/3 -translate-y-2/4 rotate-[10deg] w-[clamp(4rem,10vw,8rem)] h-[clamp(4rem,10vw,8rem)] -z-10 opacity-90 dark:invert"
<div className="shrink-0 flex justify-between items-end">
<div>
<h1
className={`${cormorant.className} text-[clamp(2rem,8vw,3.5rem)] font-light italic tracking-wide relative inline-block`}
>
Atelier
<Image
src="/images/atelier/pen.png"
alt="pen"
width={1024}
height={1024}
priority
className="absolute -right-[clamp(3rem,8vw,6.5rem)] top-1/3 -translate-y-2/4 rotate-[10deg] w-[clamp(4rem,10vw,8rem)] h-[clamp(4rem,10vw,8rem)] -z-10 opacity-90 dark:invert"
/>
</h1>
<p className="text-sm text-weak mt-1 tracking-widest uppercase">
생각들을 던져두는 곳
</p>
</div>
{/* 이전 메시지 로딩 스피너 — 공간 유지하며 숨김/표시 */}
<div className={`pb-1 ${hasMore ? '' : 'hidden'}`}>
<div
className={`w-3 h-3 rounded-full border-2 border-border border-t-weak transition-opacity duration-200 ${isLoadingOlder ? 'opacity-100 animate-spin' : 'opacity-0'}`}
/>
</h1>
<p className="text-sm text-weak mt-1 tracking-widest uppercase">
생각들을 던져두는 곳
</p>
</div>
</div>

{/* 채팅 피드 */}
Expand All @@ -172,16 +185,18 @@ const AtelierPage = () => {

{/* 입력 */}
<div className="shrink-0">
<MessageInput onSend={handleSendRoot} placeholder={placeholder} />
<MessageInput onSend={handleSendRoot} placeholder={placeholder} isAdmin={author.isAdmin} />
</div>

{/* 닉네임 게이트 (익명 방문자 전용) */}
{isGateOpen && (
<NicknameGate
onSubmit={handleSubmitNickname}
onClose={handleCloseGate}
/>
)}
{isGateOpen &&
createPortal(
<NicknameGate
onSubmit={handleSubmitNickname}
onClose={handleCloseGate}
/>,
document.body
)}
</div>
</section>
</>
Expand Down
91 changes: 31 additions & 60 deletions app/entities/atelier/ChatFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import ChatFeedSkeleton from '@/app/entities/atelier/ChatFeedSkeleton';
import MessageBubble from '@/app/entities/atelier/MessageBubble';
import { AtelierEmoji, AtelierMessage } from '@/app/types/Atelier';
Expand Down Expand Up @@ -70,59 +70,32 @@ const ChatFeed = ({
}: ChatFeedProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);

// prepend 전 스크롤 높이 저장 (loadOlder 요청 시점에 세팅)
const savedScrollHeightRef = useRef<number | null>(null);
// 최초 마운트 여부 — 첫 렌더 때만 bottom 으로 스크롤
const hasScrolledToBottomRef = useRef(false);
// 최신 메시지 수 추적 (새 메시지 도착 시 하단 유지용)
const prevMessageCountRef = useRef(0);
// isLoadingOlder를 ref로 추적 — observer effect dependency에서 제거해 재구독 루프 방지
const isLoadingOlderRef = useRef(isLoadingOlder);
useEffect(() => {
isLoadingOlderRef.current = isLoadingOlder;
}, [isLoadingOlder]);

// 초기 로드 시 하단으로 스크롤 — 페인트 전에 실행해 플래시 방지
useLayoutEffect(() => {
if (isInitialLoading) return;
if (hasScrolledToBottomRef.current) return;
if (!containerRef.current) return;
if (messages.length === 0) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
hasScrolledToBottomRef.current = true;
prevMessageCountRef.current = messages.length;
}, [isInitialLoading, messages.length]);

// prepend 시 스크롤 보정 (scrollHeight delta 만큼 내려서 위치 유지)
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
if (savedScrollHeightRef.current == null) return;
const delta = container.scrollHeight - savedScrollHeightRef.current;
if (delta > 0) {
container.scrollTop = container.scrollTop + delta;
}
savedScrollHeightRef.current = null;
}, [messages]);

// 새 메시지가 하단에 추가되면 자동 스크롤 (위쪽 prepend 는 제외)
// 새 메시지가 추가되면 자동 스크롤 (flex-col-reverse: scrollTop=0이 시각적 맨 아래)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (!hasScrolledToBottomRef.current) return;
if (savedScrollHeightRef.current != null) return;

const prev = prevMessageCountRef.current;
const next = messages.length;
if (next > prev) {
// 사용자가 거의 하단에 있을 때만 자동 스크롤
const threshold = 120;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceFromBottom < threshold) {
container.scrollTop = container.scrollHeight;
if (container.scrollTop < threshold) {
container.scrollTop = 0;
}
}
prevMessageCountRef.current = next;
}, [messages]);

// IntersectionObserver — 상단 sentinel 이 보이면 loadOlder 호출
// IntersectionObserver — sentinel이 보이면 loadOlder 호출
// isLoadingOlder는 ref로 읽어 observer 재구독 루프를 방지
useEffect(() => {
const sentinel = sentinelRef.current;
const container = containerRef.current;
Expand All @@ -132,64 +105,62 @@ const ChatFeed = ({
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
if (isLoadingOlder) return;
// prepend 전 scrollHeight 저장
savedScrollHeightRef.current = container.scrollHeight;
if (isLoadingOlderRef.current) return;
onLoadOlder();
},
{ root: container, threshold: 0.1 }
);

observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, isLoadingOlder, onLoadOlder]);
}, [hasMore, onLoadOlder]);

if (isInitialLoading) return <ChatFeedSkeleton />;

return (
// flex-col-reverse + overflow-y-auto: scrollTop=0이 시각적 맨 아래 (Discord/Slack 방식)
<div
ref={containerRef}
className="flex flex-col h-full overflow-y-auto border border-border rounded-2xl p-4 scroll-smooth"
className="relative flex flex-col-reverse h-full overflow-y-auto border border-border rounded-2xl p-4"
>
{/* 상단 sentinel — infinite scroll 트리거 */}
{/* 시각적 맨 위 sentinel — DOM 끝 = flex-col-reverse로 시각적 맨 위 */}
<div ref={sentinelRef} className="h-1" />

{hasMore && isLoadingOlder && (
<p className="text-center text-xs text-weak">이전 메시지 불러오는 중...</p>
)}

{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="flex items-center justify-center py-8">
<p className="text-weak text-sm">아직 아무것도 없어요</p>
</div>
) : (
messages
.filter((m) => m.parentId === null)
.reverse()
.map((message, idx, arr) => {
const isMine = computeIsMine(
message,
isAdmin,
currentFingerprint,
currentGithubId
);
const prev = arr[idx - 1];
const next = arr[idx + 1];
const groupedWithPrev =
!!prev &&
isSameAuthor(prev, message) &&
isWithinOneMinute(prev, message);
const groupedWithNext =
!!next &&
isSameAuthor(message, next) &&
isWithinOneMinute(message, next);
// reverse 배열: arr[idx+1]이 시각적 위(더 오래된), arr[idx-1]이 시각적 아래(더 최신)
const olderNeighbor = arr[idx + 1];
const newerNeighbor = arr[idx - 1];
const groupedWithOlder =
!!olderNeighbor &&
isSameAuthor(message, olderNeighbor) &&
isWithinOneMinute(message, olderNeighbor);
const groupedWithNewer =
!!newerNeighbor &&
isSameAuthor(message, newerNeighbor) &&
isWithinOneMinute(message, newerNeighbor);
return (
<MessageBubble
key={message._id}
message={message}
isMine={isMine}
isAdmin={isAdmin}
showAuthor={!groupedWithPrev}
showTime={!groupedWithNext}
showAuthor={!groupedWithOlder}
showTime={!groupedWithNewer}
currentFingerprint={currentFingerprint}
currentGithubId={currentGithubId}
onReact={onReact}
Expand Down
Loading
Loading