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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
| `packages/bot/src/services/round.service.ts` | 회차 관리 + ConfigKeys (announcement/notice/curation 채널) |
| `packages/web/src/components/landing/landing-client.tsx` | 랜딩 페이지 클라이언트 (7섹션: Hero, Stats, Bento, HowItWorks, Marquee, CTA, Footer) |
| `packages/web/src/components/landing/motion.tsx` | 랜딩 애니메이션 컴포넌트 (FadeUp, StaggerContainer, CountUp, DrawLine) |
| `packages/web/src/app/opengraph-image.tsx` | OG 이미지 동적 생성 (Edge Runtime, `next/og` ImageResponse, 1200×630) |
| `packages/web/public/logo.svg` | 풀 로고 SVG (픽토그램 + 텍스트) |
| `packages/bot/Dockerfile` | 봇 Docker 이미지 (multi-stage, node:22-alpine) |
| `.github/workflows/bot-deploy.yml` | 봇 CI/CD (CI Gate → ECR 빌드/푸시 → SSH 배포) |
Expand Down Expand Up @@ -174,6 +175,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
- **PWA**: 홈 화면 추가 지원 (manifest.json, 서비스 워커 없음)
- **랜딩 페이지**: Linear 스타일 다크 모드 원페이지 (큐시즘 블루 그라디언트 `#0091FF→#004DFF`, Framer Motion 풀 애니메이션, DB 스탯 ISR 60s, 인증 유저 `/dashboard` 리다이렉트)
- **로고**: 커스텀 SVG 픽토그램 (펜촉+화살표, 큐시즘 블루 그라디언트), `icon.svg`/`icon-192.png`/`icon-512.png`
- **OG 이미지**: `opengraph-image.tsx` 동적 생성 (Edge Runtime, 1200×630, 다크 테마 + Mock UI 카드), `layout.tsx`에 `openGraph`/`twitter` 메타데이터
- **토스트**: sonner (`<Toaster />` in root layout, `position="bottom-center"`, `richColors`)
- **에러 바운더리**: `(user)/error.tsx`, `(admin)/error.tsx` — Sentry 전송 + 리셋 버튼, `global-error.tsx` — 전역 폴백 (다크모드 인라인 스타일)
- **404 페이지**: `not-found.tsx` — 대시보드 링크 포함
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Blog Study Admin - 시스템 아키텍처

> 최종 업데이트: 2026-03-17 (v9)
> 최종 업데이트: 2026-03-18 (v10)

블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다.

Expand Down Expand Up @@ -448,6 +448,7 @@ erDiagram
- **Dialog/AlertDialog**: Safari PWA 스크롤 대응 — `flex flex-col` + `inset-y-0 my-auto` 센터링 + `overflow-y-auto` (grid/transform 방식은 Safari에서 클리핑 발생)
- **Pull-to-Refresh**: 커스텀 터치 제스처 기반 새로고침 (`PullToRefresh` + `usePullToRefresh`), Safari PWA 최적화, 다이얼로그 열림 시 `data-scroll-locked` 가드로 비활성화
- **PWA**: `manifest.json` + 커스텀 로고 아이콘 (SVG/192/512, maskable) → 홈 화면 추가 지원
- **OG 이미지**: `opengraph-image.tsx` Edge Runtime 동적 생성 (1200×630, `next/og` ImageResponse). 다크 테마 + K 로고 + 히어로 카피 + Mock UI 카드 (랭킹/포스트). `layout.tsx`에 `openGraph`/`twitter` 메타데이터 + `og:url`
- **FCM 푸시**: Firebase Cloud Messaging 서비스 워커 (API route `/api/firebase-sw` → rewrite `/firebase-messaging-sw.js`) → 백그라운드 알림. 타입별(댓글/답글/공지) 개별 설정, 테스트 알림 전송 지원. 푸시/점수 등 백그라운드 작업은 `after()` from `next/server` 사용

## 스케줄러 (pg-boss)
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/app/(user)/posts/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ function PostCommentItem({
setReplyContent('');
}
}}
placeholder={`@${displayName}에게 답글 달기\u2026`}
placeholder={`@${displayName}에게 답글 달기`}
rows={2}
className="resize-none text-sm leading-relaxed"
disabled={replySubmitting}
Expand Down Expand Up @@ -798,7 +798,7 @@ export default function PostDetailPage() {
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') handleSubmitComment();
}}
placeholder="댓글을 입력해주세요\u2026"
placeholder="댓글을 입력해주세요"
aria-label="댓글 입력"
rows={2}
className="resize-none text-sm leading-relaxed"
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/app/(user)/posts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ function PostCommentItem({
setReplyContent('');
}
}}
placeholder={`@${displayName}에게 답글 달기\u2026`}
placeholder={`@${displayName}에게 답글 달기`}
rows={2}
className="resize-none text-sm leading-relaxed"
disabled={replySubmitting}
Expand Down Expand Up @@ -1038,7 +1038,7 @@ function PostCard({
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') handleSubmitComment();
}}
placeholder="댓글을 입력해주세요\u2026"
placeholder="댓글을 입력해주세요"
aria-label="댓글 입력"
rows={2}
className="resize-none text-sm leading-relaxed"
Expand Down
15 changes: 14 additions & 1 deletion packages/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,21 @@ export const metadata: Metadata = {
default: '큐스팅 4th',
template: '%s | 큐스팅 4th',
},
description: '큐스팅 4th · 스터디 자동화 플랫폼',
description: '큐스팅 4th · 블로그 스터디 자동화 플랫폼',
manifest: '/manifest.json',
openGraph: {
title: '큐스팅 4th',
description: '함께 쓰고, 함께 성장하다. 블로그 스터디 자동화 플랫폼',
siteName: '큐스팅 4th',
url: 'https://cusiting.com',
locale: 'ko_KR',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: '큐스팅 4th',
description: '함께 쓰고, 함께 성장하다. 블로그 스터디 자동화 플랫폼',
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
Expand Down
279 changes: 279 additions & 0 deletions packages/web/src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { ImageResponse } from 'next/og';

export const runtime = 'edge';
export const alt = '큐스팅 4th · 블로그 스터디 자동화 플랫폼';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default function OGImage() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'row',
background: '#000000',
position: 'relative',
overflow: 'hidden',
padding: '60px 80px',
}}
>
{/* Gradient glow */}
<div
style={{
position: 'absolute',
top: '-150px',
left: '300px',
width: '800px',
height: '500px',
borderRadius: '50%',
background:
'radial-gradient(ellipse at center, rgba(0,145,255,0.18) 0%, rgba(0,77,255,0.06) 50%, transparent 70%)',
display: 'flex',
}}
/>

{/* Left: Text */}
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
flex: 1,
zIndex: 10,
paddingRight: '40px',
}}
>
{/* Logo + badge */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '14px',
marginBottom: '32px',
}}
>
<svg width="44" height="44" viewBox="0 0 512 512">
<rect width="512" height="512" rx="108" fill="#0a0a0a" />
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#0091FF" />
<stop offset="100%" stopColor="#004DFF" />
</linearGradient>
</defs>
<g fill="url(#g)">
<rect x="136" y="120" width="48" height="272" rx="8" />
<polygon points="184,268 184,228 340,120 356,120 356,152" />
<polygon points="184,268 184,308 340,392 356,392 356,360" />
<circle cx="348" cy="392" r="10" />
</g>
</svg>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
borderRadius: '999px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.05)',
padding: '6px 16px',
fontSize: '15px',
color: '#a1a1aa',
}}
>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#34d399',
display: 'flex',
}}
/>
큐스팅 4th
</div>
</div>

{/* Headline */}
<div
style={{
display: 'flex',
flexDirection: 'column',
fontSize: '52px',
fontWeight: 700,
lineHeight: 1.2,
letterSpacing: '-0.03em',
}}
>
<span style={{ color: '#ffffff' }}>함께 쓰고,</span>
<span
style={{
color: '#0078E5',
}}
>
함께 성장하다.
</span>
</div>

{/* Subtext */}
<div
style={{
display: 'flex',
flexDirection: 'column',
marginTop: '20px',
fontSize: '18px',
lineHeight: 1.6,
color: '#71717a',
}}
>
<span>2주에 한 편, 서로의 글에 응원을 나누며</span>
<span>꾸준함을 만들어가는 블로그 스터디</span>
</div>
</div>

{/* Right: Mock UI cards */}
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '14px',
width: '380px',
zIndex: 10,
}}
>
{/* Ranking card */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '10px',
borderRadius: '16px',
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.04)',
padding: '20px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px' }}>🏆</span>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#ffffff' }}>랭킹</span>
</div>
{[
{ rank: 1, name: 'alice', score: 420, bg: '#fbbf24' },
{ rank: 2, name: 'bob', score: 385, bg: '#94a3b8' },
{ rank: 3, name: 'charlie', score: 310, bg: '#fb923c' },
].map((r) => (
<div
key={r.rank}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
borderRadius: '10px',
background: 'rgba(255,255,255,0.04)',
padding: '10px 12px',
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: r.bg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 700,
color: '#000',
}}
>
{r.rank}
</div>
<span style={{ fontSize: '14px', color: '#fff', flex: 1 }}>{r.name}</span>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#60a5fa' }}>
{r.score}pt
</span>
</div>
))}
</div>

{/* Posts card */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '10px',
borderRadius: '16px',
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.04)',
padding: '20px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px' }}>📝</span>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#ffffff' }}>
최근 포스트
</span>
</div>
{[
{ title: 'React 19의 새로운 기능 정리', author: 'alice' },
{ title: 'Docker 멀티스테이지 빌드 최적화', author: 'bob' },
].map((p) => (
<div
key={p.author}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
borderRadius: '10px',
background: 'rgba(255,255,255,0.04)',
padding: '10px 12px',
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'rgba(96,165,250,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
color: '#60a5fa',
}}
>
{p.author.charAt(0).toUpperCase()}
</div>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
<span style={{ fontSize: '13px', color: '#fff' }}>{p.title}</span>
<span style={{ fontSize: '11px', color: '#52525b' }}>by {p.author}</span>
</div>
</div>
))}
</div>
</div>

{/* Bottom URL */}
<div
style={{
position: 'absolute',
bottom: '24px',
left: '0',
right: '0',
display: 'flex',
justifyContent: 'center',
fontSize: '14px',
color: '#3f3f46',
}}
>
cusiting.com
</div>
</div>
),
{ ...size },
);
}
2 changes: 1 addition & 1 deletion packages/web/src/components/board/comment-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function CommentForm({
parentIsSecret = false,
onSuccess,
onCancel,
placeholder = '댓글을 입력해주세요...',
placeholder = '댓글을 입력해주세요',
}: CommentFormProps) {
const [content, setContent] = useState('');
const [isSecret, setIsSecret] = useState(parentIsSecret);
Expand Down
Loading