├── .cursor └── rules │ └── my-custom-rule.mdc ├── .cursorignore ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── README.md ├── a.out ├── actions ├── crawl.ts ├── image.ts └── resend.ts ├── app ├── [locale] │ ├── (mdx) │ │ ├── assets │ │ │ └── MonoplexKR-Text.ttf │ │ ├── cs │ │ │ ├── [id] │ │ │ │ ├── audio │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── assets │ │ │ │ ├── chapter1.png │ │ │ │ ├── chapter2.png │ │ │ │ ├── chapter2_alt.png │ │ │ │ ├── chapter3.png │ │ │ │ ├── chasing.png │ │ │ │ ├── chasing_dark.png │ │ │ │ └── me.jpg │ │ │ ├── opengraph-image.png │ │ │ ├── page.tsx │ │ │ └── utils │ │ │ │ └── generateCSMetadata.ts │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── post │ │ │ └── [id] │ │ │ │ ├── opengraph-image.tsx │ │ │ │ └── page.tsx │ │ ├── privacy-policy │ │ │ └── page.tsx │ │ ├── react │ │ │ └── page.tsx │ │ ├── style.css │ │ └── utils │ │ │ └── getOG.tsx │ ├── [...rest] │ │ └── page.tsx │ ├── assets │ │ ├── cs.gif │ │ ├── me.jpg │ │ └── react.png │ ├── globals.css │ ├── layout.tsx │ ├── memes │ │ ├── layout.tsx │ │ └── page.tsx │ ├── not-found.tsx │ ├── opengraph-image.jpg │ └── page.tsx ├── favicon.ico └── sitemap.ts ├── biome.json ├── bun.lock ├── components ├── AuthProvider.tsx ├── PostList.tsx ├── SWRProvider.tsx ├── ScrollRestore.tsx ├── comment │ ├── Emoji.tsx │ ├── Form.tsx │ ├── NeedLogin.tsx │ ├── Viewer.tsx │ ├── assets │ │ ├── clap.webp │ │ ├── heart.webp │ │ ├── party.webp │ │ ├── rocket.webp │ │ └── sparkles.webp │ └── index.tsx ├── cs │ ├── CSPostListItem.tsx │ ├── DecimalToBinary.tsx │ ├── EmailSubscribe.tsx │ ├── PixelateImage.tsx │ ├── PostNavigation.tsx │ ├── SignalComparison.tsx │ ├── TransistorDemo.tsx │ ├── TruthTable.tsx │ ├── assets │ │ └── changdeokgung.jpg │ ├── flow │ │ ├── atoms │ │ │ ├── boolean.ts │ │ │ ├── fullAdder.ts │ │ │ ├── halfAdder.ts │ │ │ ├── index.ts │ │ │ ├── label.ts │ │ │ ├── nand.ts │ │ │ └── or.ts │ │ ├── components │ │ │ ├── BooleanNode.tsx │ │ │ ├── Controls.tsx │ │ │ ├── FullAdderNode.tsx │ │ │ ├── HalfAdderNode.tsx │ │ │ ├── LabelNode.tsx │ │ │ ├── NameTag.tsx │ │ │ ├── NandNode.tsx │ │ │ ├── OrNode.tsx │ │ │ ├── index.tsx │ │ │ └── type.ts │ │ ├── constants.ts │ │ ├── hooks │ │ │ └── useMobileState.ts │ │ ├── index.tsx │ │ └── model │ │ │ ├── type.ts │ │ │ └── useNodeAtom.ts │ └── transistor.css ├── layout │ ├── Footer.tsx │ ├── Header.tsx │ ├── LocaleSwitcher.tsx │ └── TableOfContents.tsx ├── mdx │ ├── Image.tsx │ └── Pre.tsx ├── meme │ ├── MemeCard.tsx │ ├── Tag.tsx │ ├── TagCheckbox.tsx │ └── TagRadio.tsx └── ui │ ├── Button.tsx │ ├── Checkbox.tsx │ ├── Form.tsx │ ├── Slider.tsx │ └── theme.ts ├── db ├── auth.ts ├── comment │ ├── create.ts │ ├── delete.ts │ ├── read.ts │ └── update.ts ├── index.ts ├── meme │ ├── create.ts │ ├── delete.ts │ ├── read.ts │ └── update.ts ├── memeTag │ ├── create.ts │ ├── delete.ts │ └── read.ts └── storage.ts ├── i18n ├── navigation.ts ├── request.ts ├── routing.ts └── type.ts ├── knip.json ├── lint-staged.config.mjs ├── mdx-components.tsx ├── mdx ├── blog-cursor │ ├── assets │ │ ├── action.png │ │ ├── cursor.png │ │ ├── error.png │ │ └── vibe.png │ ├── en.mdx │ └── ko.mdx ├── blog-spec │ ├── assets │ │ ├── after.png │ │ └── before.png │ └── tmp.mdx ├── cs │ ├── adder │ │ ├── assets │ │ │ ├── calculator.png │ │ │ ├── full.json │ │ │ ├── gear.png │ │ │ ├── half.json │ │ │ ├── halfNand.json │ │ │ └── ripple.json │ │ ├── en.mdx │ │ └── ko.mdx │ ├── and-or-not │ │ ├── assets │ │ │ ├── circuit.jpg │ │ │ ├── lego.jpg │ │ │ └── og.png │ │ ├── en.mdx │ │ └── ko.mdx │ ├── index.ts │ ├── nand-is-all-you-need │ │ ├── assets │ │ │ ├── and.json │ │ │ ├── not.json │ │ │ └── or.json │ │ ├── en.mdx │ │ └── ko.mdx │ └── zero-and-one │ │ ├── assets │ │ ├── cover.png │ │ ├── lp.png │ │ ├── painting.png │ │ ├── shannon.png │ │ └── zeroone.png │ │ ├── en.mdx │ │ └── ko.mdx ├── react-local-build │ └── ko.mdx ├── react │ ├── assets │ │ ├── dom-stack.png │ │ ├── effect-stack.png │ │ └── render-stack.png │ └── index.mdx ├── thenable │ ├── en.mdx │ └── ko.mdx └── use-effect │ └── _ko.mdx ├── messages ├── en.json └── ko.json ├── middleware.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public ├── cs │ ├── and-or-not │ │ └── og.png │ ├── og1.png │ ├── shannon38.pdf │ └── zero-and-one │ │ └── audio.wav ├── file.svg ├── globe.svg ├── googleb362144df7f7ef49.html ├── next.svg ├── pixelateWorker.js ├── robots.txt ├── vercel.svg └── window.svg ├── store ├── session.ts └── tempUser.ts ├── swr ├── auth.ts ├── comment.ts ├── key.ts └── meme.ts ├── tailwind.config.js ├── tsconfig.json ├── types ├── database.types.ts └── helper.types.ts ├── utils ├── array.ts ├── confetti.ts ├── error.ts ├── path.ts └── string.ts └── vercel.json /.cursor/rules/my-custom-rule.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | - Use Bun as the package manager. 7 | - Use Next.js 15 as the framework. 8 | - Use Tailwind CSS for all styling. 9 | - When implementing Server Actions, always handle errors and return a response in the following format: { success: true, value: T } | { success: false, message: string } 10 | - Prefer using throwOnError when working with Supabase to simplify error handling. 11 | - Use lucide icon package. 12 | - Use uncontrolled component for form. Prefer native html solution. 13 | - Prefer type to interface for type declaration 14 | - Prefer using the components from the /components/ui folder instead of implementing basic UI elements from scratch. 15 | - When designing components, avoid using border radius. Use constants(like colors) from components/ui/theme.ts whenever possible. 16 | - Only Next.js-related files (like layout.tsx, page.tsx, loading.tsx, etc.) should go in the app folder. Manage all components in the components folder. 17 | - Keep things simple. Prefer simpicity over reusability unless explicitly mentioned. 18 | - Prefer short variable name. index -> idx, value -> val -------------------------------------------------------------------------------- /.cursorignore: -------------------------------------------------------------------------------- 1 | # Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | # python 44 | python/venv/ 45 | __pycache__/ 46 | 47 | # mcp 48 | .cursor/mcp.json -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | bunx lint-staged -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.mdx 2 | *.md 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "always", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "biomejs.biome", 4 | "[mdx]": { 5 | "editor.defaultFormatter": "unifiedjs.vscode-mdx" 6 | }, 7 | // "editor.codeActionsOnSave": { 8 | // "source.fixAll.biome": "explicit", 9 | // "source.organizeImports.biome": "explicit" 10 | // }, 11 | "[json]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "[tailwindcss]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "[cpp]": { 18 | "editor.defaultFormatter": "ms-vscode.cpptools" 19 | }, 20 | "i18n-ally.localesPaths": ["i18n", "messages"] 21 | } 22 | -------------------------------------------------------------------------------- /a.out: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/a.out -------------------------------------------------------------------------------- /actions/crawl.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import { getErrMessage } from '@/utils/string'; 3 | import chromium from '@sparticuz/chromium'; 4 | import { delay } from 'es-toolkit'; 5 | import puppeteer, { type Browser } from 'puppeteer'; 6 | import puppeteerCore from 'puppeteer-core'; 7 | 8 | const VIEWPORT = { width: 375, height: 667 }; 9 | const DELAY_TIME = 500; 10 | 11 | const getBrowser = (() => { 12 | chromium.setGraphicsMode = false; 13 | 14 | let browser: Browser; 15 | 16 | return async () => { 17 | if (browser) return browser; 18 | 19 | // @ts-expect-error 귀찮음 20 | browser = 21 | process.env.NODE_ENV === 'development' 22 | ? await puppeteer.launch({ 23 | headless: false, 24 | defaultViewport: VIEWPORT, 25 | }) 26 | : await puppeteerCore.launch({ 27 | args: [...chromium.args], 28 | executablePath: await chromium.executablePath( 29 | 'https://github.com/Sparticuz/chromium/releases/download/v133.0.0/chromium-v133.0.0-pack.tar', 30 | ), 31 | headless: 'shell', 32 | defaultViewport: VIEWPORT, 33 | }); 34 | 35 | return browser; 36 | }; 37 | })(); 38 | 39 | async function crawlInstagram(url: string) { 40 | const browser = await getBrowser(); 41 | const page = await browser.newPage(); 42 | 43 | try { 44 | const urlWithoutQuery = url.split('?')[0]; 45 | 46 | await page.setExtraHTTPHeaders({ 47 | 'Accept-Language': 'ko-KR', 48 | }); 49 | 50 | await page.goto(urlWithoutQuery, { 51 | waitUntil: 'networkidle0', 52 | timeout: 15000, 53 | }); 54 | 55 | // 팝업 닫기 56 | const closeButton = await page.waitForSelector('svg[aria-label="닫기"]', { 57 | timeout: 5000, 58 | visible: true, 59 | }); 60 | const buttonPosition = await closeButton?.boundingBox(); 61 | if (!buttonPosition) throw new Error('닫기을 찾지 못했습니다.'); 62 | 63 | const x = buttonPosition.x + buttonPosition.width / 2; 64 | const y = buttonPosition.y + buttonPosition.height / 2; 65 | await page.mouse.click(x, y); 66 | 67 | // 잠시 대기 68 | await delay(300); 69 | 70 | const clickNext = async () => { 71 | const nextButton = await page 72 | .waitForSelector('button[aria-label="다음"]', { 73 | timeout: DELAY_TIME, 74 | }) 75 | .catch(() => null); 76 | if (!nextButton) return false; 77 | 78 | await nextButton.click(); 79 | return true; 80 | }; 81 | 82 | // 이미지 가져오기 83 | const images: string[] = []; 84 | let retry = false; 85 | 86 | while (true) { 87 | const imageUrl = await page.evaluate(({ width, height }) => { 88 | const sibling = document.elementFromPoint( 89 | width / 2, 90 | height / 3, 91 | ) as HTMLElement; 92 | const parent = sibling.parentElement; 93 | const img = parent?.querySelector('img'); 94 | 95 | if (img instanceof HTMLImageElement) return img.src; 96 | return null; 97 | }, VIEWPORT); 98 | 99 | if (!imageUrl) break; 100 | 101 | // 버튼이 한번씩 무시돼서 재시도 로직이 있어야 함 102 | if (images.includes(imageUrl)) { 103 | if (retry) break; 104 | retry = true; 105 | await clickNext(); 106 | await delay(DELAY_TIME); 107 | continue; 108 | } 109 | retry = false; 110 | 111 | images.push(imageUrl); 112 | 113 | await clickNext(); 114 | await delay(DELAY_TIME); 115 | } 116 | 117 | return images; 118 | } finally { 119 | await page.close(); 120 | } 121 | } 122 | 123 | export async function crawlInstagramAction(url: string) { 124 | try { 125 | return await crawlInstagram(url); 126 | } catch (e) { 127 | return getErrMessage(e); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /actions/image.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { getErrMessage } from '@/utils/string'; 4 | import sharp from 'sharp'; 5 | 6 | // https://github.com/lovell/sharp/issues/4250 7 | // webp로 바꿔야하나... 8 | export const fileToAVIFAction = async (file: File) => { 9 | const url = URL.createObjectURL(file); 10 | try { 11 | const resp = await fetch(url); 12 | const buffer = await resp.arrayBuffer(); 13 | const avif = await sharp(buffer).avif().toBuffer(); 14 | return avif; 15 | } catch (e) { 16 | return getErrMessage(e); 17 | } finally { 18 | URL.revokeObjectURL(url); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /actions/resend.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import { wrapServerAction } from '@/utils/error'; 3 | import { revalidatePath } from 'next/cache'; 4 | import { Resend } from 'resend'; 5 | 6 | // Resend 객체 생성 7 | const resend = new Resend(process.env.RESEND_API_KEY); 8 | 9 | // 오디언스 ID 설정 (Resend 대시보드에서 생성한 ID) 10 | // biome-ignore lint/style/noNonNullAssertion: 없으면 터지는게 맞음 11 | const AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID!; 12 | 13 | export const subscribeEmail = wrapServerAction(async (email: string) => { 14 | const resp = await resend.contacts.create({ 15 | email, 16 | audienceId: AUDIENCE_ID, 17 | }); 18 | if (resp.error) { 19 | throw new Error(resp.error.message); 20 | } 21 | 22 | // TODO: 이 페이지 말고 다른 곳에서도 구독자 수를 사용한다면...? 23 | revalidatePath('/cs'); 24 | revalidatePath('/en/cs'); 25 | }); 26 | 27 | // 구독자 수 조회 함수 28 | export const getSubscriberCount = wrapServerAction(async () => { 29 | const response = await resend.contacts.list({ audienceId: AUDIENCE_ID }); 30 | return response.data?.data.length; 31 | }); 32 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/assets/MonoplexKR-Text.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/(mdx)/assets/MonoplexKR-Text.ttf -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/[id]/audio/page.tsx: -------------------------------------------------------------------------------- 1 | import { generateCSMetadata } from '@/app/[locale]/(mdx)/cs/utils/generateCSMetadata'; 2 | import { Link } from '@/i18n/navigation'; 3 | 4 | export const generateMetadata = generateCSMetadata; 5 | 6 | export default async function AudioPage({ 7 | params, 8 | }: { 9 | params: Promise<{ id: string }>; 10 | }) { 11 | const { id } = await params; 12 | return ( 13 |
14 | {/* biome-ignore lint/a11y/useMediaCaption: 캡션이 없어 */} 15 | 18 |

AI로 생성해본 팟캐스트 파일입니다☺️

19 |

20 | 글 보러가기 21 |

22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { generateCSMetadata } from '@/app/[locale]/(mdx)/cs/utils/generateCSMetadata'; 2 | import Comments from '@/components/comment'; 3 | import PostNavigation from '@/components/cs/PostNavigation'; 4 | import TableOfContents from '@/components/layout/TableOfContents'; 5 | import { routing } from '@/i18n/routing'; 6 | import { getPostIds } from '@/utils/path'; 7 | import { notFound } from 'next/navigation'; 8 | 9 | export const dynamic = 'force-dynamic'; 10 | export const generateMetadata = generateCSMetadata; 11 | 12 | export default async function PostPage({ 13 | params, 14 | }: { 15 | params: Promise<{ id: string; locale: string }>; 16 | }) { 17 | const { id, locale } = await params; 18 | 19 | try { 20 | const { default: Component, title } = await import( 21 | `@/mdx/cs/${id}/${locale}.mdx` 22 | ); 23 | 24 | return ( 25 | <> 26 |
27 |

{title}

28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 |
38 | 39 | ); 40 | } catch (error) { 41 | notFound(); 42 | } 43 | } 44 | 45 | export const generateStaticParams = async () => { 46 | const locales = routing.locales; 47 | const result = []; 48 | 49 | for (const locale of locales) { 50 | const postIds = await getPostIds(locale, 'cs'); 51 | for (const postId of postIds) { 52 | result.push({ id: postId, locale }); 53 | } 54 | } 55 | 56 | return result; 57 | }; 58 | 59 | export const dynamicParams = false; 60 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/assets/chapter1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/(mdx)/cs/assets/chapter1.png -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/assets/chapter2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/(mdx)/cs/assets/chapter2.png -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/assets/chapter2_alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/(mdx)/cs/assets/chapter2_alt.png -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/assets/chapter3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/(mdx)/cs/assets/chapter3.png -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/assets/chasing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/(mdx)/cs/assets/chasing.png -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/assets/chasing_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/(mdx)/cs/assets/chasing_dark.png -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/assets/me.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/(mdx)/cs/assets/me.jpg -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/(mdx)/cs/opengraph-image.png -------------------------------------------------------------------------------- /app/[locale]/(mdx)/cs/utils/generateCSMetadata.ts: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | 3 | type Props = { 4 | params: Promise<{ id: string; locale: string }>; 5 | }; 6 | 7 | export async function generateCSMetadata({ params }: Props): Promise { 8 | const { id, locale } = await params; 9 | const { title, description, ogImage } = await import( 10 | `@/mdx/cs/${id}/${locale}.mdx` 11 | ); 12 | const vercelURL = process.env.VERCEL_PROJECT_PRODUCTION_URL; 13 | 14 | return { 15 | metadataBase: 16 | process.env.NODE_ENV === 'development' 17 | ? new URL('http://localhost:3000') 18 | : new URL(vercelURL ? `https://${vercelURL}` : 'https://yeolyi.com'), 19 | title, 20 | description, 21 | openGraph: { 22 | title, 23 | description, 24 | images: ogImage ? [ogImage] : undefined, 25 | }, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/layout.tsx: -------------------------------------------------------------------------------- 1 | import localFont from 'next/font/local'; 2 | 3 | // css가 script 태그로 들어가서 mdx 안에서 import해서 그런건가싶어서 여기로 이동 4 | import './style.css'; 5 | import 'medium-zoom/dist/style.css'; 6 | 7 | const monoplexKR = localFont({ 8 | src: './assets/MonoplexKR-Text.ttf', 9 | variable: '--font-monoplex-kr', 10 | }); 11 | 12 | export default async function PostLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | params: Promise<{ id: string }>; 17 | }) { 18 | return ( 19 |
22 | {children} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/loading.tsx: -------------------------------------------------------------------------------- 1 | export default async function Loading() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |

10 | 이거 어떻게 없애지 이거 어떻게 없애지 이거 어떻게 없애지 이거 어떻게 11 | 없애지 12 |

13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/post/[id]/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import getOG, { 2 | alt, 3 | contentType, 4 | size, 5 | } from '@/app/[locale]/(mdx)/utils/getOG'; 6 | export { alt, contentType, size }; 7 | 8 | export default async function OpengraphImage({ 9 | params, 10 | }: { 11 | params: Promise<{ id: string; locale: string }>; 12 | }) { 13 | const { id, locale } = await params; 14 | return getOG({ id, locale }); 15 | } 16 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/post/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Comments from '@/components/comment'; 2 | import TableOfContents from '@/components/layout/TableOfContents'; 3 | import { routing } from '@/i18n/routing'; 4 | import { getPostIds } from '@/utils/path'; 5 | import type { Metadata } from 'next'; 6 | import { notFound } from 'next/navigation'; 7 | 8 | export const dynamic = 'force-dynamic'; 9 | 10 | type Props = { 11 | params: Promise<{ id: string; locale: string }>; 12 | }; 13 | 14 | export async function generateMetadata({ params }: Props): Promise { 15 | const { id, locale } = await params; 16 | const { title, description } = await import(`@/mdx/${id}/${locale}.mdx`); 17 | return { title, description }; 18 | } 19 | 20 | export default async function PostPage({ 21 | params, 22 | }: { 23 | params: Promise<{ id: string; locale: string }>; 24 | }) { 25 | const { id, locale } = await params; 26 | 27 | try { 28 | const { default: Component, title } = await import( 29 | `@/mdx/${id}/${locale}.mdx` 30 | ); 31 | 32 | return ( 33 | <> 34 |
35 |

{title}

36 | 37 |
38 | 39 |
40 | 41 |
42 | 43 | ); 44 | } catch (error) { 45 | notFound(); 46 | } 47 | } 48 | 49 | export const generateStaticParams = async () => { 50 | const locales = routing.locales; 51 | const result = []; 52 | 53 | for (const locale of locales) { 54 | const postIds = await getPostIds(locale); 55 | for (const postId of postIds) { 56 | result.push({ id: postId, locale }); 57 | } 58 | } 59 | 60 | return result; 61 | }; 62 | 63 | export const dynamicParams = false; 64 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/privacy-policy/page.tsx: -------------------------------------------------------------------------------- 1 | export default function PrivacyPolicy() { 2 | return ( 3 |
4 |

개인정보처리방침

5 |

6 | yeolyi.com은「개인정보 보호법」등 관련 법령에 따라 이용자의 개인정보를 7 | 보호하고, 권익을 존중하며, 아래와 같은 내용으로 개인정보를 처리합니다. 8 |

9 |
    10 |
  1. 11 | 수집하는 개인정보 항목 12 |
      13 |
    • 수집 항목: 이메일 주소
    • 14 |
    • 수집 방법: 홈페이지를 통한 직접 입력
    • 15 |
    16 |
  2. 17 |
  3. 18 | 개인정보의 수집 및 이용 목적 19 |
      20 |
    • 뉴스레터 발송
    • 21 |
    • 비영리 정보 제공 및 커뮤니케이션
    • 22 |
    23 |
  4. 24 |
  5. 25 | 개인정보 보유 및 이용 기간 26 |
      27 |
    • 이용자가 구독 해지를 요청할 때까지 보유 및 이용
    • 28 |
    • 구독 해지 시 즉시 파기
    • 29 |
    30 |
  6. 31 |
  7. 32 | 개인정보 제3자 제공 33 |
      34 |
    • 수집된 이메일은 제3자에게 제공하지 않습니다.
    • 35 |
    36 |
  8. 37 |
  9. 38 | 개인정보 처리 위탁 39 |
      40 |
    • 41 | 수집된 개인정보는 Resend(이메일 서비스)를 통해 안전하게 42 | 저장·관리됩니다. 43 |
    • 44 |
    45 |
  10. 46 |
  11. 47 | 정보주체의 권리 48 |
      49 |
    • 50 | 이용자는 언제든지 구독 해지, 개인정보 열람·수정·삭제를 요청할 수 51 | 있습니다. 52 |
    • 53 |
    • 문의: 이성열(yeolyi1310@gmail.com)
    • 54 |
    55 |
  12. 56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/react/page.tsx: -------------------------------------------------------------------------------- 1 | import Comments from '@/components/comment'; 2 | import TableOfContents from '@/components/layout/TableOfContents'; 3 | 4 | export default async function PostPage() { 5 | const { default: Component } = await import('@/mdx/react/index.mdx'); 6 | 7 | return ( 8 | <> 9 |
10 | 11 |
12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 | ); 20 | } 21 | 22 | export const dynamicParams = false; 23 | -------------------------------------------------------------------------------- /app/[locale]/(mdx)/utils/getOG.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from 'next/og'; 2 | 3 | export const alt = '블로그 포스트 오픈그래프 이미지'; 4 | export const contentType = 'image/png'; 5 | export const size = { 6 | width: 1200, 7 | height: 630, 8 | }; 9 | 10 | export default async function getOG({ 11 | id, 12 | locale, 13 | subDir, 14 | }: { 15 | id: string; 16 | locale: string; 17 | subDir?: string; 18 | }) { 19 | const mdxModule = subDir 20 | ? await import(`@/mdx/${subDir}/${id}/${locale}.mdx`) 21 | : await import(`@/mdx/${id}/${locale}.mdx`); 22 | const { title } = mdxModule; 23 | 24 | const headerText = 25 | locale === 'ko' ? '성열 | yeolyi.com' : 'seongyeol Yi | yeolyi.com'; 26 | const fontText = `${headerText} ${title}`; 27 | const fontData = await loadGoogleFont( 28 | 'IBM+Plex+Sans+KR:wght@700;800', 29 | fontText, 30 | ); 31 | 32 | return new ImageResponse( 33 | 34 | 35 | 36 | , 37 | { 38 | ...size, 39 | fonts: [ 40 | { 41 | name: 'IBM Plex Sans KR', 42 | data: fontData, 43 | style: 'normal', 44 | weight: 800, 45 | }, 46 | ], 47 | }, 48 | ); 49 | } 50 | 51 | function OGLayout({ children }: { children: React.ReactNode }) { 52 | return ( 53 |
66 | {children} 67 |
68 | ); 69 | } 70 | 71 | function TitleArea({ title }: { title: string }) { 72 | return ( 73 |
89 | {title} 90 |
91 | ); 92 | } 93 | 94 | function HeaderArea({ 95 | headerText, 96 | profileImageUrl, 97 | }: { 98 | headerText: string; 99 | profileImageUrl?: string; 100 | }) { 101 | return ( 102 | <> 103 | {/* 하단 여백 */} 104 |
105 | 106 | {/* 좌하단 헤더 영역 */} 107 |
115 | 121 | {headerText} 122 | 123 | {profileImageUrl && ( 124 | 프로필 이미지 134 | )} 135 |
136 | 137 | ); 138 | } 139 | 140 | async function loadGoogleFont(font: string, text: string) { 141 | const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}`; 142 | const css = await (await fetch(url)).text(); 143 | const resource = css.match( 144 | /src: url\((.+)\) format\('(opentype|truetype|woff2?)'\)/, 145 | ); 146 | 147 | if (resource) { 148 | const response = await fetch(resource[1]); 149 | if (response.status === 200) { 150 | return await response.arrayBuffer(); 151 | } 152 | } 153 | 154 | throw new Error('폰트 데이터 로드 실패'); 155 | } 156 | -------------------------------------------------------------------------------- /app/[locale]/[...rest]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | 3 | // https://next-intl.dev/docs/environments/error-files 4 | export default function CatchAllPage() { 5 | notFound(); 6 | } 7 | -------------------------------------------------------------------------------- /app/[locale]/assets/cs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/assets/cs.gif -------------------------------------------------------------------------------- /app/[locale]/assets/me.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/assets/me.jpg -------------------------------------------------------------------------------- /app/[locale]/assets/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/assets/react.png -------------------------------------------------------------------------------- /app/[locale]/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "@tailwindcss/typography"; 3 | @config "../../tailwind.config.js"; 4 | 5 | :root { 6 | --toastify-toast-bd-radius: 0px; 7 | } 8 | 9 | @layer base { 10 | body { 11 | font-family: var(--font-ibm-plex-sans); 12 | @apply bg-stone-900; 13 | scrollbar-gutter: stable; 14 | color-scheme: dark; 15 | } 16 | 17 | /* 1. Use a more-intuitive box-sizing model */ 18 | *, 19 | *::before, 20 | *::after { 21 | box-sizing: border-box; 22 | } 23 | 24 | /* 2. Remove default margin */ 25 | * { 26 | margin: 0; 27 | } 28 | 29 | /* 3. Enable keyword animations */ 30 | @media (prefers-reduced-motion: no-preference) { 31 | html { 32 | /* biome-ignore lint/correctness/noUnknownProperty: https://developer.mozilla.org/en-US/docs/Web/CSS/interpolate-size*/ 33 | interpolate-size: allow-keywords; 34 | } 35 | } 36 | 37 | body { 38 | /* 4. Add accessible line-height */ 39 | line-height: 1.5; 40 | /* 5. Improve text rendering */ 41 | -webkit-font-smoothing: antialiased; 42 | } 43 | 44 | /* 6. Improve media defaults */ 45 | img, 46 | picture, 47 | video, 48 | canvas, 49 | svg { 50 | display: block; 51 | max-width: 100%; 52 | } 53 | 54 | /* 7. Inherit fonts for form controls */ 55 | input, 56 | button, 57 | textarea, 58 | select { 59 | font: inherit; 60 | } 61 | 62 | /* 8. Avoid text overflows */ 63 | p, 64 | h1, 65 | h2, 66 | h3, 67 | h4, 68 | h5, 69 | h6 { 70 | overflow-wrap: break-word; 71 | } 72 | 73 | /* 9. Improve line wrapping */ 74 | p { 75 | text-wrap: pretty; 76 | } 77 | h1, 78 | h2, 79 | h3, 80 | h4, 81 | h5, 82 | h6 { 83 | text-wrap: balance; 84 | } 85 | 86 | /* 87 | 10. Create a root stacking context 88 | */ 89 | #root, 90 | #__next { 91 | isolation: isolate; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/[locale]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from '@vercel/analytics/react'; 2 | import { IBM_Plex_Sans_KR } from 'next/font/google'; 3 | import Footer from '../../components/layout/Footer'; 4 | import Header from '../../components/layout/Header'; 5 | 6 | import '@/app/[locale]/globals.css'; 7 | 8 | import { AuthProvider } from '@/components/AuthProvider'; 9 | import SWRProvider from '@/components/SWRProvider'; 10 | import ScrollRetoration from '@/components/ScrollRestore'; 11 | import { routing } from '@/i18n/routing'; 12 | import { Provider as JotaiProvider } from 'jotai'; 13 | import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl'; 14 | import { setRequestLocale } from 'next-intl/server'; 15 | import { notFound } from 'next/navigation'; 16 | import type * as React from 'react'; 17 | import { Suspense } from 'react'; 18 | import { Slide, ToastContainer } from 'react-toastify'; 19 | 20 | const ibmPlexSans = IBM_Plex_Sans_KR({ 21 | variable: '--font-ibm-plex-sans', 22 | subsets: ['latin'], 23 | weight: ['400', '500', '600', '700'], 24 | }); 25 | 26 | export async function generateMetadata({ 27 | params, 28 | }: { 29 | params: Promise<{ locale: Locale }>; 30 | }) { 31 | const { locale } = await params; 32 | 33 | return { 34 | title: locale === 'ko' ? '이성열' : 'seongyeol Yi', 35 | description: 36 | locale === 'ko' 37 | ? '만든 것들과 배운 것들을 여기 공유해요' 38 | : 'I share what I make and learn here.', 39 | }; 40 | } 41 | 42 | export default async function RootLayout({ 43 | children, 44 | params, 45 | }: Readonly<{ 46 | children: React.ReactNode; 47 | params: Promise<{ locale: Locale }>; 48 | }>) { 49 | const { locale } = await params; 50 | if (!hasLocale(routing.locales, locale)) { 51 | notFound(); 52 | } 53 | 54 | // Enable static rendering 55 | setRequestLocale(locale); 56 | 57 | return ( 58 | 63 | 64 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | {children} 77 |
78 |