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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ ADR 작성 기준:
- `blog/`: 글 도메인. post schema, repository, publication policy, series,
blog UI, RSS feed serialization, view-count use case
- `resume/`: 이력서 도메인. resume data, ordering, resume UI
- `search/`: 검색 도메인. command palette, search action, recommendation
- `site/`: 도메인 조합 layer. home, AppShell, navigation, provider, site config
- `infra/`: 외부/런타임 인프라. Supabase, Umami analytics, SEO helper,
integration adapter
Expand Down
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@
<br>TypeScript
</td>
<td align="center" width="96">
<img src="https://skillicons.dev/icons?i=threejs" width="48" height="48" alt="Three.js" />
<br>Three.js
</td>
<td align="center" width="96">
<img src="https://skillicons.dev/icons?i=tailwind" width="48" height="48" alt="CSS" />
<br>Tailwind
</td>
Expand All @@ -49,7 +45,6 @@

**애니메이션:**

- **3D:** Three.js + @react-three/fiber + @react-three/drei
- **모션:** Framer Motion

**콘텐츠 처리:**
Expand Down Expand Up @@ -80,7 +75,6 @@ ark/
├── 📁 app/ # Next.js route adapter
├── 📁 blog/ # 글 도메인
├── 📁 resume/ # 이력서 도메인
├── 📁 search/ # 검색 도메인
├── 📁 site/ # 홈, AppShell, provider, site config
├── 📁 infra/ # Supabase, Umami, SEO integration
├── 📁 ui/ # 도메인 지식 없는 UI/레이아웃/모션
Expand Down
12 changes: 6 additions & 6 deletions app/api/og/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function GET(req: NextRequest) {
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
backgroundImage: 'linear-gradient(to bottom right, #E0F2FF, #fff)',
backgroundImage: 'linear-gradient(to bottom right, #EAEBEA, #E0E1E3)',
padding: '80px',
}}
>
Expand All @@ -56,7 +56,7 @@ export async function GET(req: NextRequest) {
<div
style={{
fontSize: '30px',
color: '#6b7684',
color: '#52525B',
}}
>
{date}
Expand All @@ -66,7 +66,7 @@ export async function GET(req: NextRequest) {
style={{
fontSize: '60px',
fontWeight: 700,
color: '#191f28',
color: '#252525',
lineHeight: 1.2,
wordBreak: 'keep-all',
}}
Expand All @@ -86,8 +86,8 @@ export async function GET(req: NextRequest) {
key={tag}
style={{
display: 'flex',
backgroundColor: 'rgba(49, 130, 246, 0.1)',
color: '#3182f6',
backgroundColor: '#E0E1E3',
color: '#3F3F46',
padding: '8px 24px',
borderRadius: '50px',
fontSize: '24px',
Expand All @@ -114,7 +114,7 @@ export async function GET(req: NextRequest) {
style={{
fontSize: '32px',
fontWeight: 700,
color: '#3182f6',
color: '#3F3F46',
fontFamily: 'Pretendard',
}}
>
Expand Down
8 changes: 5 additions & 3 deletions app/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ describe('root layout font loading', () => {
expect(layoutContent).not.toContain('href="/fonts/TossFaceFontWeb.otf"');
});

it('passes a public client DTO instead of full editorial metadata', () => {
expect(layoutContent).toContain('selectClientPosts(getSortedFeedData())');
expect(layoutContent).toContain('<AppProviders posts={posts}>');
it('passes only navigation metadata to the app shell', () => {
expect(layoutContent).toContain('getSortedFeedData().map');
expect(layoutContent).toContain('<AppProviders>');
expect(layoutContent).toContain('<AppShell posts={posts}>');
expect(layoutContent).not.toContain('selectClientPosts');
});

it('keeps Tossface demand-driven for matching emoji glyphs', () => {
Expand Down
13 changes: 7 additions & 6 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import '@/styles/tossface.css';

import AppProviders from '@/site/providers/AppProviders';
import { getSortedFeedData } from '@/blog/services/post-repository';
import { selectClientPosts } from '@/site/providers/client-posts';
import { AppShell } from '@/site/shell/AppShell';
import {
SITE_AUTHOR,
Expand Down Expand Up @@ -55,16 +54,19 @@ export const metadata: Metadata = {
};

export const viewport: Viewport = {
themeColor: '#ffffff',
themeColor: '#EAEBEA',
width: 'device-width',
initialScale: 1,
};

export default function RootLayout({ children }: { children: ReactNode }) {
const posts = selectClientPosts(getSortedFeedData());
const posts = getSortedFeedData().map(({ slug, category }) => ({
slug,
category,
}));

return (
<html lang="ko" suppressHydrationWarning>
<html lang="ko">
<head>
<link
rel="preload"
Expand All @@ -75,11 +77,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
/>
</head>
<body>
<AppProviders posts={posts}>
<AppProviders>
<div id="app-root">
<AppShell posts={posts}>{children}</AppShell>
</div>
<div id="overlay-root" />
</AppProviders>
</body>
</html>
Expand Down
2 changes: 1 addition & 1 deletion app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function NotFound() {
</p>
<Link
href="/"
className="mt-8 inline-flex min-h-11 items-center justify-center rounded-[var(--radius-action)] bg-[var(--color-toss-blue)] px-5 text-sm font-semibold text-white transition-colors hover:bg-[var(--color-toss-blue-dark)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-primary)]"
className="mt-8 inline-flex min-h-11 items-center justify-center rounded-[var(--radius-action)] bg-[var(--color-toss-blue)] px-5 text-sm font-semibold text-[var(--color-accent-foreground)] transition-colors hover:bg-[var(--color-toss-blue-dark)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-primary)]"
>
홈으로 돌아가기
</Link>
Expand Down
2 changes: 1 addition & 1 deletion blog/services/markdown-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('parseHeadingsFromMdx', () => {

describe('getMdxSource', () => {
it('returns markdown source for an existing slug', () => {
const source = getMdxSource('redis-deep-dive-02-core-data-types');
const source = getMdxSource('llm-wiki-build-retrospective');

expect(typeof source).toBe('string');
expect(source).toContain('#');
Expand Down
22 changes: 7 additions & 15 deletions blog/services/post-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,13 @@ ${'x'.repeat(5000)}
expect(hasExtractedImage).toBe(true);
});

it('returns series posts sorted by order', () => {
const redisPosts = getSeriesPosts('redis-deep-dive', {
includePrivate: true,
});

expect(redisPosts.length).toBeGreaterThan(0);
const orders = redisPosts.map((post) => post.series?.order ?? 0);
expect(orders).toEqual([...orders].sort((a, b) => a - b));
it('does not retain learning series migrated to llm-wiki', () => {
expect(getSeriesPosts('redis-deep-dive', { includePrivate: true })).toEqual(
[]
);
expect(getSeriesPosts('flink-mastery', { includePrivate: true })).toEqual(
[]
);
});

it('returns empty array for unknown series id', () => {
Expand Down Expand Up @@ -112,13 +111,6 @@ ${'x'.repeat(5000)}
expect(await getFeedData(privateSlug)).toBeNull();
});

it('filters private series posts from helper lookups', () => {
expect(getSeriesPosts('redis-deep-dive')).toEqual([]);
expect(
getSeriesPosts('redis-deep-dive', { includePrivate: true }).length
).toBeGreaterThan(0);
});

it('returns null for non-existent posts folder slug', () => {
expect(getFolderSlug('missing-folder-slug')).toBeNull();
});
Expand Down
14 changes: 8 additions & 6 deletions blog/ui/components/CategoryFilter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,28 +79,30 @@ describe('CategoryFilter', () => {
expect(onCategoryChange).toHaveBeenCalledWith('Life');
});

it('renders active style classes', () => {
it('renders the active category as an underlined text control', () => {
render(
<CategoryFilter
{...baseProps}
activeCategory="Life"
onCategoryChange={vi.fn()}
variant="links"
/>
);

const lifeButton = screen
.getAllByRole('button')
.find((button) => button.textContent === 'Life2');
.find((button) => button.textContent === 'Life(2)');

expect(lifeButton).toBeDefined();

if (!lifeButton) {
throw new Error('Life category button not found');
}

expect(lifeButton).toHaveClass('bg-[var(--color-toss-blue)]');
expect(lifeButton).toHaveClass('rounded-[var(--radius-selection)]');
expect(lifeButton).not.toHaveClass('shadow-sm');
expect(lifeButton).not.toHaveClass('active:translate-y-px');
expect(lifeButton).toHaveClass('border-b-2');
expect(lifeButton).toHaveClass('border-[var(--color-toss-blue)]');
expect(lifeButton).toHaveClass('text-[var(--color-toss-blue)]');
expect(lifeButton).not.toHaveClass('rounded-[var(--radius-selection)]');
expect(lifeButton).not.toHaveClass('bg-[var(--color-toss-blue)]');
});
});
79 changes: 53 additions & 26 deletions blog/ui/components/CategoryFilter/CategoryFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { clsx } from 'clsx';

type Category = 'All' | 'Tech' | 'Life';
type CategoryFilterVariant = 'links' | 'pills';

const CATEGORY_LABELS: Record<Category, string> = {
All: 'All',
Expand All @@ -15,44 +16,70 @@ interface CategoryFilterProps {
activeCategory: Category;
onCategoryChange: (category: Category) => void;
categoryCounts: Record<Category, number>;
variant?: CategoryFilterVariant;
}

export default function CategoryFilter({
categories,
activeCategory,
onCategoryChange,
categoryCounts,
variant = 'pills',
}: CategoryFilterProps) {
const isLinkVariant = variant === 'links';

return (
<div className="flex gap-2 flex-wrap">
{categories.map((category) => (
<button
key={category}
onClick={() => onCategoryChange(category)}
className={clsx(
'inline-flex min-h-11 items-center gap-2 rounded-[var(--radius-selection)] border px-4 py-2 text-sm transition-colors duration-[var(--duration-200)] ease-[var(--ease-default)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-primary)]',
activeCategory === category
? 'border-[var(--color-toss-blue)] bg-[var(--color-toss-blue)] text-white'
: 'border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-grey-600)] hover:border-[var(--color-border-hover)] hover:bg-[var(--color-grey-50)]'
)}
aria-pressed={activeCategory === category}
>
<span className="font-medium">{CATEGORY_LABELS[category]}</span>
<span
<nav aria-label="글 분류">
<div
className={clsx(
'flex flex-wrap items-center',
isLinkVariant ? 'gap-x-5 gap-y-1' : 'gap-2'
)}
>
{categories.map((category) => (
<button
key={category}
onClick={() => onCategoryChange(category)}
className={clsx(
'rounded-[var(--radius-selection)] px-1.5 py-0.5 text-[10px] font-semibold',
activeCategory === category
? 'bg-white/20 text-white'
: 'bg-[var(--color-grey-100)] text-[var(--color-text-tertiary)]'
'inline-flex min-h-11 items-center text-sm transition-colors duration-[var(--duration-200)] ease-[var(--ease-default)]',
isLinkVariant
? 'border-b-2 px-0 py-2 font-medium'
: 'gap-2 rounded-[var(--radius-selection)] border px-4 py-2',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-primary)]',
isLinkVariant
? activeCategory === category
? 'border-[var(--color-toss-blue)] text-[var(--color-toss-blue)]'
: 'border-transparent text-[var(--color-text-secondary)] hover:border-[var(--color-grey-300)] hover:text-[var(--color-text-primary)]'
: activeCategory === category
? 'border-[var(--color-toss-blue)] bg-[var(--color-toss-blue)] text-[var(--color-accent-foreground)]'
: 'border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-grey-600)] hover:border-[var(--color-border-hover)] hover:bg-[var(--color-grey-50)]'
)}
aria-pressed={activeCategory === category}
>
{categoryCounts[category]}
</span>
</button>
))}
</div>
<span className={clsx(!isLinkVariant && 'font-medium')}>
{CATEGORY_LABELS[category]}
</span>
{isLinkVariant ? (
<span className="ml-1 text-meta font-normal text-[var(--color-text-tertiary)]">
({categoryCounts[category]})
</span>
) : (
<span
className={clsx(
'rounded-[var(--radius-selection)] px-1.5 py-0.5 text-[10px] font-semibold',
activeCategory === category
? 'bg-[var(--color-grey-700)] text-[var(--color-accent-foreground)]'
: 'bg-[var(--color-grey-100)] text-[var(--color-text-tertiary)]'
)}
>
{categoryCounts[category]}
</span>
)}
</button>
))}
</div>
</nav>
);
}

export type { CategoryFilterProps, Category };
export type { CategoryFilterProps, Category, CategoryFilterVariant };
Loading
Loading