├── .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 |
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 |
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 | -
11 | 수집하는 개인정보 항목
12 |
13 | - 수집 항목: 이메일 주소
14 | - 수집 방법: 홈페이지를 통한 직접 입력
15 |
16 |
17 | -
18 | 개인정보의 수집 및 이용 목적
19 |
20 | - 뉴스레터 발송
21 | - 비영리 정보 제공 및 커뮤니케이션
22 |
23 |
24 | -
25 | 개인정보 보유 및 이용 기간
26 |
27 | - 이용자가 구독 해지를 요청할 때까지 보유 및 이용
28 | - 구독 해지 시 즉시 파기
29 |
30 |
31 | -
32 | 개인정보 제3자 제공
33 |
34 | - 수집된 이메일은 제3자에게 제공하지 않습니다.
35 |
36 |
37 | -
38 | 개인정보 처리 위탁
39 |
40 | -
41 | 수집된 개인정보는 Resend(이메일 서비스)를 통해 안전하게
42 | 저장·관리됩니다.
43 |
44 |
45 |
46 | -
47 | 정보주체의 권리
48 |
49 | -
50 | 이용자는 언제든지 구독 해지, 개인정보 열람·수정·삭제를 요청할 수
51 | 있습니다.
52 |
53 | - 문의: 이성열(yeolyi1310@gmail.com)
54 |
55 |
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 |
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 |
79 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | );
102 | }
103 |
104 | export async function generateStaticParams() {
105 | return routing.locales.map((locale) => ({ locale }));
106 | }
107 |
--------------------------------------------------------------------------------
/app/[locale]/memes/layout.tsx:
--------------------------------------------------------------------------------
1 | import { type ReactNode, Suspense } from 'react';
2 |
3 | export default function MemesLayout({ children }: { children: ReactNode }) {
4 | return {children};
5 | }
6 |
--------------------------------------------------------------------------------
/app/[locale]/not-found.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Link } from '@/i18n/navigation';
4 | import { useTranslations } from 'next-intl';
5 |
6 | export default function PostNotFound() {
7 | const t = useTranslations('NotFound');
8 |
9 | return (
10 |
11 |
404 Not Found
12 |
16 | {t('backHome')}
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/app/[locale]/opengraph-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/[locale]/opengraph-image.jpg
--------------------------------------------------------------------------------
/app/[locale]/page.tsx:
--------------------------------------------------------------------------------
1 | import PostList from '@/components/PostList';
2 | import { border, layerBg } from '@/components/ui/theme';
3 | import { Link } from '@/i18n/navigation';
4 | import clsx from 'clsx';
5 | import type { Locale } from 'next-intl';
6 | import { getTranslations, setRequestLocale } from 'next-intl/server';
7 | import Image from 'next/image';
8 | import type { StaticImageData } from 'next/image';
9 | import cs from './assets/cs.gif';
10 | import me from './assets/me.jpg';
11 | import react from './assets/react.png';
12 |
13 | export type PostType = {
14 | titleKey: string;
15 | title?: string;
16 | descriptionKey?: string;
17 | description?: string;
18 | } & (
19 | | {
20 | isPublished: false;
21 | }
22 | | {
23 | isPublished: true;
24 | slug: string;
25 | }
26 | );
27 |
28 | export type PartType = {
29 | id: string;
30 | titleKey: string;
31 | title?: string;
32 | image?: StaticImageData;
33 | posts: PostType[];
34 | };
35 |
36 | export default async function Home({
37 | params,
38 | }: {
39 | params: Promise<{ locale: Locale }>;
40 | }) {
41 | const { locale } = await params;
42 | // Enable static rendering
43 | setRequestLocale(locale);
44 |
45 | const t = await getTranslations('HomePage');
46 |
47 | return (
48 |
49 |
55 |
56 |
57 | {t.rich('bio', {
58 | snuLink: (chunks) => (
59 | {chunks}
60 | ),
61 | kakaoLink: (chunks) => (
62 | {chunks}
63 | ),
64 | instagramLink: (chunks) => (
65 | {chunks}
66 | ),
67 | })}
68 |
69 |
70 |
71 |
{t('posts')}
72 |
73 |
74 |
75 |
76 |
77 | {t('series')}
78 |
79 |
80 |
81 | {locale === 'ko' && (
82 |
88 | )}
89 |
90 |
91 |
92 | );
93 | }
94 |
95 | const SeriesCard = ({
96 | href,
97 | imgClassName,
98 | src,
99 | title,
100 | }: {
101 | imgClassName?: string;
102 | href: string;
103 | src: StaticImageData;
104 | title: string;
105 | }) => (
106 |
114 |
123 |
129 | {title}
130 |
131 |
132 | );
133 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/app/favicon.ico
--------------------------------------------------------------------------------
/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { generateStaticParams as generateStaticCsParams } from '@/app/[locale]/(mdx)/cs/[id]/page';
2 | import { generateStaticParams as generateStaticPostParams } from '@/app/[locale]/(mdx)/post/[id]/page';
3 | import { routing } from '@/i18n/routing';
4 | import type { MetadataRoute } from 'next';
5 |
6 | const BASE_URL = `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
7 |
8 | export default async function sitemap(): Promise {
9 | const postStaticParams = await generateStaticPostParams();
10 | const csStaticParams = await generateStaticCsParams();
11 |
12 | return [
13 | {
14 | url: BASE_URL,
15 | lastModified: new Date(),
16 | changeFrequency: 'weekly',
17 | priority: 1,
18 | },
19 | {
20 | url: `${BASE_URL}/en`,
21 | lastModified: new Date(),
22 | changeFrequency: 'weekly',
23 | priority: 1,
24 | },
25 | {
26 | url: `${BASE_URL}/cs`,
27 | lastModified: new Date(),
28 | changeFrequency: 'weekly',
29 | priority: 1,
30 | },
31 | {
32 | url: `${BASE_URL}/en/cs`,
33 | lastModified: new Date(),
34 | changeFrequency: 'weekly',
35 | priority: 1,
36 | },
37 | ...csStaticParams.map(({ id, locale }) => {
38 | const localeUrl = locale === routing.defaultLocale ? '' : `${locale}/`;
39 | return {
40 | url: `${BASE_URL}/${localeUrl}cs/${id}`,
41 | lastModified: new Date(),
42 | changeFrequency: 'weekly' as const,
43 | priority: 0.8,
44 | };
45 | }),
46 | ...postStaticParams.map(({ id, locale }) => {
47 | const localeUrl = locale === routing.defaultLocale ? '' : `${locale}/`;
48 | return {
49 | url: `${BASE_URL}/${localeUrl}post/${id}`,
50 | lastModified: new Date(),
51 | changeFrequency: 'weekly' as const,
52 | priority: 0.8,
53 | };
54 | }),
55 | ];
56 | }
57 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": {
4 | "enabled": true,
5 | "clientKind": "git",
6 | "useIgnoreFile": true
7 | },
8 | "files": {
9 | "ignoreUnknown": false,
10 | "ignore": []
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "space"
15 | },
16 | "organizeImports": {
17 | "enabled": true
18 | },
19 | "linter": {
20 | "enabled": true,
21 | "rules": {
22 | "recommended": true,
23 | "correctness": {
24 | "noUnusedImports": "error"
25 | },
26 | "suspicious": {
27 | "noArrayIndexKey": "off"
28 | }
29 | }
30 | },
31 | "javascript": {
32 | "formatter": {
33 | "quoteStyle": "single"
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/components/AuthProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { initializeAuthListener } from '@/store/session';
4 | import { useEffect } from 'react';
5 |
6 | export function AuthProvider({ children }: { children: React.ReactNode }) {
7 | useEffect(() => {
8 | return initializeAuthListener();
9 | }, []);
10 |
11 | return <>{children}>;
12 | }
13 |
--------------------------------------------------------------------------------
/components/PostList.tsx:
--------------------------------------------------------------------------------
1 | import { skewOnHover } from '@/components/ui/theme';
2 | import { Link } from '@/i18n/navigation';
3 | import { getPostIds } from '@/utils/path';
4 | import clsx from 'clsx';
5 | import { getLocale } from 'next-intl/server';
6 |
7 | export default async function PostList() {
8 | const locale = await getLocale();
9 | const ids = await getPostIds(locale);
10 | const metadataList = await Promise.all(
11 | ids.map(async (id) => {
12 | const { default: _, ...metadata } = await import(
13 | `@/mdx/${id}/${locale}.mdx`
14 | );
15 | return { id, ...metadata };
16 | }),
17 | );
18 | const sortedMetadataList = metadataList.sort((a, b) => {
19 | return new Date(b.date).getTime() - new Date(a.date).getTime();
20 | });
21 |
22 | return (
23 |
24 | {sortedMetadataList.map(({ id, title, date }) => (
25 | -
26 |
30 |
36 | {date}
37 |
38 |
41 | {title}
42 |
43 |
44 |
45 | ))}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/components/SWRProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { toast } from 'react-toastify';
3 | import { SWRConfig } from 'swr';
4 |
5 | export default function SWRProvider({
6 | children,
7 | }: {
8 | children: React.ReactNode;
9 | }) {
10 | return (
11 | {
14 | toast.error(error.message);
15 | },
16 | }}
17 | >
18 | {children}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/components/ScrollRestore.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useSearchParams } from 'next/navigation';
4 | import { useLayoutEffect } from 'react';
5 |
6 | const ScrollRestore = () => {
7 | const searchParams = useSearchParams();
8 |
9 | useLayoutEffect(() => {
10 | const scrollY = searchParams.get('scrollY');
11 | if (!scrollY) return;
12 |
13 | setTimeout(() => {
14 | // 스크롤 위치 복원
15 | window.scrollTo({
16 | top: Number.parseInt(scrollY, 10),
17 | behavior: 'instant',
18 | });
19 |
20 | // 쿼리 파라미터에서 scrollY 제거
21 | const newParams = new URLSearchParams(searchParams.toString());
22 | newParams.delete('scrollY');
23 | const newPathname = window.location.pathname;
24 | const newSearch = newParams.toString() ? `?${newParams.toString()}` : '';
25 |
26 | // URL 수정 (히스토리 대체)
27 | window.history.replaceState({}, '', `${newPathname}${newSearch}`);
28 |
29 | // TODO: 최소화
30 | }, 200);
31 | }, [searchParams]);
32 |
33 | return null;
34 | };
35 |
36 | export default ScrollRestore;
37 |
--------------------------------------------------------------------------------
/components/comment/Emoji.tsx:
--------------------------------------------------------------------------------
1 | import { bgMap, border } from '@/components/ui/theme';
2 | import { addEmojiReactionInDB } from '@/db/comment/update';
3 | import { useSessionStore } from '@/store/session';
4 | import { useEmojiComment } from '@/swr/comment';
5 | import { confetti } from '@/utils/confetti';
6 | import clsx from 'clsx';
7 | import Image from 'next/image';
8 | import clap from './assets/clap.webp';
9 | import heart from './assets/heart.webp';
10 | import party from './assets/party.webp';
11 | import rocket from './assets/rocket.webp';
12 | import sparkles from './assets/sparkles.webp';
13 |
14 | const DEFAULT_EMOJIS = ['👍', '❤️', '🎉'] as const;
15 | const EMOJI_TO_ANIMATED = {
16 | // thumb_up이 못생겨서 대체
17 | '👍': clap,
18 | '❤️': heart,
19 | '🎉': party,
20 | '✨': sparkles,
21 | '🚀': rocket,
22 | };
23 | const EMOJI_TO_COLOR = {
24 | '👍': ['#FFD700', '#FFEB3B', '#FFF176', '#FBC02D', '#F57F17', '#FFF9C4'],
25 | '❤️': ['#FF4081', '#F06292', '#E91E63', '#FF8A80', '#F50057', '#FFF1F3'],
26 | '🎉': undefined,
27 | '✨': ['#FFF176', '#FFF59D', '#FFEB3B', '#FDD835', '#FFFDE7', '#FFD600'],
28 | '🚀': ['#F44336', '#2196F3', '#90CAF9', '#FF7043', '#B0BEC5', '#ECEFF1'],
29 | };
30 |
31 | const strIsEmoji = (str: string): str is keyof typeof EMOJI_TO_COLOR => {
32 | return str in EMOJI_TO_COLOR;
33 | };
34 |
35 | export default function Emoji({ postId }: { postId: string }) {
36 | const session = useSessionStore((state) => state.session);
37 | const { data, mutate } = useEmojiComment(postId);
38 |
39 | const reactionArr = DEFAULT_EMOJIS.map((emoji) => {
40 | const reaction = data?.find((reaction) => reaction.emoji === emoji);
41 | return {
42 | emoji,
43 | count: reaction?.count ?? 0,
44 | user_reacted: reaction?.user_reacted ?? false,
45 | };
46 | });
47 |
48 | const onClick =
49 | (emoji: string, count: number, user_reacted: boolean) =>
50 | async (e: React.MouseEvent) => {
51 | // 1. await 이후 React가 리렌더링하며 이벤트 핸들러와 관련된 DOM이 제거되었을 가능성 있음.
52 | // 2. 이벤트 풀링: React는 SyntheticEvent(합성 이벤트)를 사용합니다.
53 | // 이건 브라우저의 Native Event를 감싸서 크로스 브라우징 이슈를 줄이고 성능을 개선하기 위한 방식입니다.
54 | const target = e.currentTarget;
55 | const rect = target.getBoundingClientRect();
56 | const x = (rect.x + rect.width / 2) / window.innerWidth;
57 | const y = (rect.y + rect.height / 2) / window.innerHeight;
58 |
59 | // 이미 반응한 경우 무시
60 | if (user_reacted) {
61 | if (!strIsEmoji(emoji)) return;
62 | confetti({ origin: { x, y }, colors: EMOJI_TO_COLOR[emoji] });
63 | return;
64 | }
65 |
66 | await addEmojiReactionInDB({
67 | postId,
68 | emoji,
69 | session,
70 | });
71 |
72 | // 빠른 confetti를 위해 굳이 await하지 않음
73 | mutate((prev) =>
74 | prev?.map((reaction) => {
75 | if (reaction.emoji === emoji) {
76 | return { ...reaction, count: count + 1 };
77 | }
78 | return reaction;
79 | }),
80 | );
81 |
82 | if (!strIsEmoji(emoji)) return;
83 | confetti({ origin: { x, y }, colors: EMOJI_TO_COLOR[emoji] });
84 | };
85 |
86 | return (
87 |
88 | {reactionArr.map(({ emoji, count, user_reacted }) => (
89 |
108 | ))}
109 |
110 | );
111 | }
112 |
--------------------------------------------------------------------------------
/components/comment/Form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import Button from '@/components/ui/Button';
3 | import { border } from '@/components/ui/theme';
4 | import { useProfile } from '@/swr/auth';
5 | import { createComment, useComments } from '@/swr/comment';
6 | import { getErrMessage } from '@/utils/string';
7 | import { Pencil } from 'lucide-react';
8 | import { useTranslations } from 'next-intl';
9 | import { useActionState } from 'react';
10 | import { toast } from 'react-toastify';
11 |
12 | type CommentFormProps = {
13 | postId: string;
14 | };
15 |
16 | export default function CommentForm({ postId }: CommentFormProps) {
17 | const { data: profile } = useProfile();
18 | const { data: comments } = useComments(postId);
19 | const t = useTranslations('Comment');
20 |
21 | const isCommentEmpty = comments?.length === 0;
22 |
23 | const onSubmit = async (prevState: string | undefined, values: FormData) => {
24 | const content = values.get('content');
25 | if (typeof content !== 'string') return;
26 |
27 | try {
28 | if (!profile) return '유저 아이디를 불러올 수 없어요';
29 | await createComment(postId, content, profile.id);
30 | } catch (e) {
31 | toast.error(getErrMessage(e));
32 | return '';
33 | }
34 |
35 | return;
36 | };
37 |
38 | const [state, formAction, isPending] = useActionState(onSubmit, undefined);
39 |
40 | return (
41 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/components/comment/NeedLogin.tsx:
--------------------------------------------------------------------------------
1 | import { border } from '@/components/ui/theme';
2 | import { useSessionStore } from '@/store/session';
3 | import { useTranslations } from 'next-intl';
4 |
5 | export default function NeedLogin() {
6 | const t = useTranslations('Comment');
7 | const login = useSessionStore((state) => state.login);
8 | const isLoading = useSessionStore((state) => state.isLoading);
9 |
10 | return (
11 |
12 | {isLoading
13 | ? t('loading')
14 | : t.rich('loginRequired', {
15 | loginLink: (chunks) => (
16 |
23 | ),
24 | })}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/components/comment/Viewer.tsx:
--------------------------------------------------------------------------------
1 | import Button from '@/components/ui/Button';
2 | import { layerBg } from '@/components/ui/theme';
3 | import { Link } from '@/i18n/navigation';
4 | import { useProfile } from '@/swr/auth';
5 | import { deleteComment, useComments } from '@/swr/comment';
6 | import type { Comment } from '@/types/helper.types';
7 | import dayjs from 'dayjs';
8 | import { Check, Trash, X } from 'lucide-react';
9 | import { useTranslations } from 'next-intl';
10 | import { useState } from 'react';
11 |
12 | interface CommentListProps {
13 | postId: string;
14 | }
15 |
16 | export default function CommentList({ postId }: CommentListProps) {
17 | const { data: comments } = useComments(postId);
18 |
19 | return (
20 | <>
21 | {comments?.map((comment) => (
22 |
23 | ))}
24 | >
25 | );
26 | }
27 |
28 | const CommentItem = ({
29 | comment,
30 | postId,
31 | }: {
32 | comment: Comment;
33 | postId: string;
34 | }) => {
35 | const headerT = useTranslations('Header');
36 | const commentT = useTranslations('Comment');
37 | const { data: profile } = useProfile();
38 |
39 | const githubId = comment.github_id;
40 | const githubUrl = githubId ? `https://github.com/${githubId}` : '#';
41 | const isAuthor = profile?.id === comment.author_id;
42 |
43 | return (
44 |
47 |
48 |
49 | {headerT('developer', { number: comment.developernumber })}
50 |
51 |
52 |
53 | {` ${dayjs(comment.created_at).format(commentT('dateFormat'))}`}
54 |
55 |
56 |
57 |
58 | {comment.content}
59 |
60 | {isAuthor &&
}
61 |
62 | );
63 | };
64 |
65 | const DeleteButton = ({
66 | postId,
67 | commentId,
68 | }: {
69 | postId: string;
70 | commentId: string;
71 | }) => {
72 | const [asked, setAsked] = useState(false);
73 |
74 | if (asked) {
75 | return (
76 |
77 |
90 | );
91 | }
92 |
93 | return (
94 | setAsked(true)}
97 | bg="transparent"
98 | Icon={Trash}
99 | className="absolute top-0 right-0"
100 | />
101 | );
102 | };
103 |
--------------------------------------------------------------------------------
/components/comment/assets/clap.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/components/comment/assets/clap.webp
--------------------------------------------------------------------------------
/components/comment/assets/heart.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/components/comment/assets/heart.webp
--------------------------------------------------------------------------------
/components/comment/assets/party.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/components/comment/assets/party.webp
--------------------------------------------------------------------------------
/components/comment/assets/rocket.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/components/comment/assets/rocket.webp
--------------------------------------------------------------------------------
/components/comment/assets/sparkles.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/components/comment/assets/sparkles.webp
--------------------------------------------------------------------------------
/components/comment/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import NeedLogin from '@/components/comment/NeedLogin';
3 | import { useSessionStore } from '@/store/session';
4 | import Emoji from './Emoji';
5 | import CommentForm from './Form';
6 | import CommentList from './Viewer';
7 |
8 | export default function Comment({ postId }: { postId: string }) {
9 | const session = useSessionStore((state) => state.session);
10 |
11 | return (
12 |
13 |
14 | {session ? : }
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/components/cs/CSPostListItem.tsx:
--------------------------------------------------------------------------------
1 | import { skewOnHover } from '@/components/ui/theme';
2 | import { Link } from '@/i18n/navigation';
3 | import clsx from 'clsx';
4 | import { CornerDownRight } from 'lucide-react';
5 | import { useFormatter, useTranslations } from 'next-intl';
6 |
7 | export default function CSPostListItem(
8 | props:
9 | | {
10 | href: string;
11 | title: string;
12 | description: string;
13 | date?: string;
14 | children: React.ReactNode;
15 | }
16 | | {
17 | title: string;
18 | description: string;
19 | date?: string;
20 | },
21 | ) {
22 | const t = useTranslations('Curriculum');
23 | const formatter = useFormatter();
24 |
25 | const { title, description, date } = props;
26 |
27 | // TODO: 이게 최선?
28 | const href = 'href' in props ? props.href : undefined;
29 | const children = 'children' in props ? props.children : null;
30 |
31 | return (
32 |
33 |
34 |
41 | {!href && (
42 |
48 | {date
49 | ? `⏰ ${formatter.relativeTime(new Date(date), new Date())}`
50 | : t('comingSoon')}
51 |
52 | )}
53 | {title}
54 |
55 |
62 | {description}
63 | {' '}
64 | {date && href && (
65 |
66 | {` ${t('dateFormat', { date: new Date(date) })}`}
67 |
68 | )}
69 |
70 |
71 | {children && (
72 |
73 |
78 |
{children}
79 |
80 | )}
81 |
82 | );
83 | }
84 |
85 | // 날짜 문자열을 파싱하고 번역 파일의 형식으로 변환하는 함수
86 | function formatDate(
87 | dateString: string,
88 | t: (key: string, values?: Record) => string,
89 | ) {
90 | const date = new Date(dateString);
91 | const year = date.getFullYear();
92 | const month = date.toLocaleString('default', { month: 'long' });
93 | const day = date.getDate();
94 |
95 | return t('dateFormat', { year, month, day });
96 | }
97 |
98 | const Container = ({
99 | href,
100 | children,
101 | }: {
102 | href?: string;
103 | children: React.ReactNode;
104 | }) => {
105 | if (href) {
106 | return (
107 |
108 | {children}
109 |
110 | );
111 | }
112 | return (
113 | {children}
114 | );
115 | };
116 |
--------------------------------------------------------------------------------
/components/cs/DecimalToBinary.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Input } from '@/components/ui/Form';
4 | import { layerBg } from '@/components/ui/theme';
5 | import clsx from 'clsx';
6 | import { useState } from 'react';
7 |
8 | export default function DecimalToBinary() {
9 | const [val, setVal] = useState('5');
10 |
11 | const num = Number.parseInt(val);
12 | const binary = num.toString(2);
13 | const digits = binary.split('').map((bit, idx) => ({
14 | bit: Number(bit),
15 | power: binary.length - idx - 1,
16 | value: bit === '1' ? 2 ** (binary.length - idx - 1) : 0,
17 | }));
18 |
19 | const nonZeroDigits = digits.filter((d) => d.bit === 1);
20 |
21 | return (
22 |
23 |
{
27 | const numberOnly = e.target.value.replace(/[^0-9]/g, '');
28 | if (numberOnly.length > 10) {
29 | setVal(numberOnly.slice(0, 10));
30 | } else {
31 | setVal(numberOnly);
32 | }
33 | }}
34 | placeholder="십진수를 입력하세요"
35 | />
36 |
37 | {!Number.isNaN(num) && (
38 |
39 |
40 | {digits.map((d) => d.bit).join('')}
41 | 2
42 |
43 |
44 |
45 | {' = '}
46 |
47 | {nonZeroDigits.map((d, idx) => (
48 |
49 | {d.bit}
50 | {' × '}
51 |
52 | 2{d.power}
53 |
54 | {idx < nonZeroDigits.length - 1 && ' + '}
55 |
56 | ))}
57 |
58 |
59 | {' = '}
60 |
61 | {nonZeroDigits.map((d, idx) => (
62 |
63 | {d.value}
64 | {idx < nonZeroDigits.length - 1 && (
65 | +
66 | )}
67 |
68 | ))}
69 | =
70 | {num}
71 | 10
72 |
73 | )}
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/components/cs/EmailSubscribe.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { subscribeEmail } from '@/actions/resend';
4 | import Button from '@/components/ui/Button';
5 | import { Input } from '@/components/ui/Form';
6 | import { confetti } from '@/utils/confetti';
7 | import { Send } from 'lucide-react';
8 | import { useTranslations } from 'next-intl';
9 | import { useActionState, useRef } from 'react';
10 | import { toast } from 'react-toastify';
11 |
12 | export default function EmailSubscribe() {
13 | const t = useTranslations('EmailSubscribe');
14 | const ref = useRef(null);
15 |
16 | const onSubmit = async (prevState: string | null, formData: FormData) => {
17 | const email = formData.get('email');
18 | if (typeof email !== 'string') return prevState;
19 |
20 | const result = await subscribeEmail(email);
21 | if (!result.success) {
22 | toast.error(result.value);
23 | return '';
24 | }
25 |
26 | const rect = ref.current?.getBoundingClientRect();
27 | if (rect) {
28 | confetti({
29 | origin: {
30 | x: 0.5,
31 | y: (rect.y + rect.height) / window.innerHeight,
32 | },
33 | colors: ['#FFD60A', '#FF375F', '#32D74B', '#0A84FF', '#FF9F0A'],
34 | });
35 | }
36 | return t('successMessage');
37 | };
38 |
39 | const [state, formAction, isPending] = useActionState(onSubmit, null);
40 |
41 | return (
42 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/components/cs/PixelateImage.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Slider from '@/components/ui/Slider';
4 | import { useTranslations } from 'next-intl';
5 | import Image from 'next/image';
6 | import { useCallback, useEffect, useRef, useState } from 'react';
7 |
8 | import { layerBg } from '@/components/ui/theme';
9 | import clsx from 'clsx';
10 | import { debounce } from 'es-toolkit';
11 | // https://pixabay.com/photos/changdeokgung-palace-garden-786592/
12 | import changdeokgung from './assets/changdeokgung.jpg';
13 |
14 | // TODO: 고해상도로 도전해보기
15 | export default function PixelateImage() {
16 | const t = useTranslations('ZeroAndOne.PixelateImage');
17 | const [pixelCntPow, setPixelCntPow] = useState(5);
18 |
19 | const [pixelatedImageSrc, setPixelatedImageSrc] = useState('');
20 | const [canvasRef, setCanvasRef] = useState(null);
21 | const [imageRef, setImageRef] = useState(null);
22 | const [originalLoaded, setOriginalLoaded] = useState(false);
23 | const workerRef = useRef(null);
24 |
25 | const pixelCnt = 2 ** pixelCntPow;
26 |
27 | // 웹 워커 초기화
28 | useEffect(() => {
29 | if (typeof window === 'undefined') return;
30 |
31 | workerRef.current = new Worker('/pixelateWorker.js');
32 | workerRef.current.onmessage = (e) => {
33 | if (e.data.status === 'success') {
34 | setPixelatedImageSrc(e.data.result);
35 | } else {
36 | console.error('웹 워커 오류:', e.data.error);
37 | }
38 | };
39 |
40 | return () => {
41 | workerRef.current?.terminate();
42 | };
43 | }, []);
44 |
45 | const handleImageLoad = () => {
46 | setOriginalLoaded(true);
47 | };
48 |
49 | // 픽셀 크기가 변경되거나 이미지가 로드되면 픽셀화 실행
50 | useEffect(() => {
51 | if (originalLoaded && canvasRef && imageRef && workerRef.current) {
52 | processImageWithWorker(canvasRef, imageRef, pixelCnt);
53 | }
54 | }, [originalLoaded, pixelCnt, canvasRef, imageRef]);
55 |
56 | const processImageWithWorker = useCallback(
57 | debounce(
58 | (canvas: HTMLCanvasElement, img: HTMLImageElement, pixelCnt: number) => {
59 | // 캔버스 크기를 이미지 크기로 설정
60 | canvas.width = img.naturalWidth;
61 | canvas.height = img.naturalHeight;
62 |
63 | // 이미지 데이터 가져오기
64 | const ctx = canvas.getContext('2d', { willReadFrequently: true });
65 | if (!ctx) return;
66 | ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
67 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
68 |
69 | // 웹 워커로 데이터 전송
70 | workerRef.current?.postMessage(
71 | {
72 | imageData: imageData.data.buffer,
73 | pixelCnt,
74 | width: canvas.width,
75 | height: canvas.height,
76 | },
77 | // ArrayBuffer를 전송하므로 메모리 복사 방지를 위해 전송??
78 | [imageData.data.buffer],
79 | );
80 | },
81 | // TODO: 100ms안에는 되겠지...? 일단 겹치는건 처리 안함
82 | 100,
83 | ),
84 | [],
85 | );
86 |
87 | return (
88 |
94 |
102 |
103 |
104 | {t('digitalizedImage')} ({pixelCnt}x{pixelCnt})
105 |
106 |
107 |
108 | {pixelatedImageSrc && (
109 |

116 | )}
117 |
118 |
129 |
130 | {/* 숨겨진 캔버스 */}
131 |
132 |
133 | );
134 | }
135 |
--------------------------------------------------------------------------------
/components/cs/PostNavigation.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ChevronLeft, ChevronRight, List } from 'lucide-react';
4 |
5 | import { Link } from '@/i18n/navigation';
6 | import { order } from '@/mdx/cs';
7 | import clsx from 'clsx';
8 | import { useTranslations } from 'next-intl';
9 |
10 | export default function PostNavigation({
11 | id,
12 | className,
13 | }: {
14 | id: string;
15 | className?: string;
16 | }) {
17 | const t = useTranslations('PostNavigation');
18 | const tCurriculum = useTranslations('Curriculum');
19 |
20 | const currentIndex = order.indexOf(id);
21 | const prevPostIndex = currentIndex > 0 ? currentIndex - 1 : null;
22 | const nextPostIndex =
23 | currentIndex < order.length - 1 ? currentIndex + 1 : null;
24 |
25 | const prevPostId = prevPostIndex !== null ? order[prevPostIndex] : null;
26 | const nextPostId = nextPostIndex !== null ? order[nextPostIndex] : null;
27 |
28 | return (
29 |
35 | {prevPostId ? (
36 |
40 |
41 |
{t('prev')}
42 |
43 | ) : (
44 |
45 | )}
46 |
47 |
51 |
52 | {t('backToList')}
53 |
54 |
55 | {nextPostId ? (
56 |
60 |
{t('next')}
61 |
62 |
63 | ) : (
64 |
65 | )}
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/components/cs/SignalComparison.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Slider from '@/components/ui/Slider';
4 | import { border, failBg, layerBg, successBg } from '@/components/ui/theme';
5 | import type React from 'react';
6 | import { useCallback, useEffect, useState } from 'react';
7 |
8 | // 신호 시각화 컴포넌트
9 | interface SignalVisualizerProps {
10 | value: number;
11 | range: [number, number];
12 | }
13 |
14 | const SignalVisualizer: React.FC = ({
15 | value,
16 | range,
17 | }) => {
18 | const isError = value < range[0] || value > range[1];
19 | return (
20 |
21 | {/* 유효 범위 상한선 */}
22 |
26 |
27 | 유효 상한: {range[1].toFixed(2)}
28 |
29 |
30 |
31 | {/* 유효 범위 하한선 */}
32 |
36 |
37 | 유효 하한: {range[0].toFixed(2)}
38 |
39 |
40 |
41 | {/* 신호값 */}
42 |
51 |
52 | );
53 | };
54 |
55 | export default function SignalComparison(): React.ReactElement {
56 | const [errorRate, setErrorRate] = useState(10); // 기본 오류율 10%
57 | const [noisyBinarySignal, setNoisyBinarySignal] = useState(0.75);
58 | const [noisyDecimalSignal, setNoisyDecimalSignal] = useState(0.5);
59 |
60 | // 랜덤 노이즈 적용
61 | const applyRandomNoise = useCallback(() => {
62 | const noise = (Math.random() * 2 - 1) * (errorRate / 100);
63 |
64 | const getNoisySignal = (original: number): number => {
65 | return Math.max(0, Math.min(1, original + noise));
66 | };
67 |
68 | setNoisyBinarySignal(getNoisySignal(1));
69 | setNoisyDecimalSignal(getNoisySignal(0.5));
70 | }, [errorRate]);
71 |
72 | useEffect(() => {
73 | applyRandomNoise();
74 | const id = setInterval(applyRandomNoise, 1000);
75 | return () => clearInterval(id);
76 | }, [applyRandomNoise]);
77 |
78 | return (
79 |
80 |
81 |
82 |
83 | 이진법 목표값 1
84 |
85 |
86 |
87 |
88 |
89 |
90 | 십진법 목표값 5
91 |
92 |
93 |
94 |
95 |
96 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/components/cs/TransistorDemo.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import './transistor.css';
5 | import { Checkbox } from '@/components/ui/Checkbox';
6 | import { layerBg } from '@/components/ui/theme';
7 |
8 | export default function TransistorDemo() {
9 | const [isFlowing, setIsFlowing] = useState(false);
10 |
11 | return (
12 |
15 | {/* 상단 배선 (드레인/컬렉터) */}
16 |
20 |
21 | {/* 트랜지스터 본체 */}
22 |
23 | {/* 게이트 라인 및 입력 */}
24 |
25 |
26 | Gate
27 |
28 | setIsFlowing(checked === true)}
31 | />
32 |
33 |
34 |
35 |
36 |
37 | {/* 하단 배선 (소스/이미터) */}
38 |
39 |
40 |
Source (S)
41 |
42 |
43 | {/* 전류 흐름 표시 */}
44 | {isFlowing && (
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | )}
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/components/cs/TruthTable.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Label } from '@/components/ui/Form';
4 | import { failBg, layerBg, successBg } from '@/components/ui/theme';
5 | import clsx from 'clsx';
6 | import { useId, useState } from 'react';
7 | import { Checkbox } from '../ui/Checkbox';
8 |
9 | type LabelType = 'input' | 'output';
10 |
11 | type TableLabel = {
12 | label: string;
13 | type: LabelType;
14 | };
15 |
16 | type TruthTableGateProps = {
17 | /**
18 | * 라벨 객체 배열. type이 'input'이면 입력, 'output'이면 출력
19 | * @example AND 게이트
20 | * [
21 | * { label: 'A', type: 'input' },
22 | * { label: 'B', type: 'input' },
23 | * { label: 'A AND B', type: 'output' }
24 | * ]
25 | */
26 | labels: TableLabel[];
27 |
28 | /**
29 | * 진리표 데이터
30 | * @example AND 게이트 (2개 입력, 1개 출력)
31 | * [
32 | * [false, false, false], // A=0, B=0, 출력=0
33 | * [false, true, false], // A=0, B=1, 출력=0
34 | * [true, false, false], // A=1, B=0, 출력=0
35 | * [true, true, true] // A=1, B=1, 출력=1
36 | * ]
37 | */
38 | data: boolean[][];
39 | };
40 |
41 | export default function TruthTable({ labels, data }: TruthTableGateProps) {
42 | const id = useId();
43 |
44 | const inputLabels = labels.filter((l) => l.type === 'input');
45 |
46 | const [inputs, setInputs] = useState(() => {
47 | const inputs = Array(inputLabels.length).fill(false);
48 | return inputs;
49 | });
50 |
51 | const matchingRowIndex = data.findIndex((row) => {
52 | return inputs.every((val, idx) => val === row[idx]);
53 | });
54 |
55 | const handleCheckedChange = (index: number) => (checked: boolean) => {
56 | const newInputs = [...inputs];
57 | newInputs[index] = checked;
58 | setInputs(newInputs);
59 | };
60 |
61 | return (
62 |
63 |
64 |
65 |
66 | {labels.map((labelObj, index) => (
67 |
71 | {labelObj.type === 'input' ? (
72 |
73 |
74 |
80 |
81 | ) : (
82 | labelObj.label
83 | )}
84 | |
85 | ))}
86 |
87 |
88 |
89 | {data.map((row, rowIdx) => (
90 |
91 | {row.map((cell, colIdx) => (
92 |
104 | {cell ? '1' : '0'}
105 | |
106 | ))}
107 |
108 | ))}
109 |
110 |
111 |
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/components/cs/assets/changdeokgung.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/components/cs/assets/changdeokgung.jpg
--------------------------------------------------------------------------------
/components/cs/flow/atoms/boolean.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | NodeAtoms,
3 | NodeCreator,
4 | OutputValue,
5 | } from '@/components/cs/flow/model/type';
6 | import { atom } from 'jotai';
7 |
8 | type BooleanAtoms = NodeAtoms;
9 |
10 | export const createBooleanAtoms: NodeCreator = (
11 | initialValues,
12 | ) => {
13 | const outAtom = atom(initialValues?.out ?? false);
14 | const labelAtom = atom(initialValues?.label ?? null);
15 |
16 | return {
17 | type: 'boolean',
18 | inputAtoms: {},
19 | outputAtoms: { out: outAtom, label: labelAtom },
20 | effectAtom: undefined,
21 | };
22 | };
23 |
--------------------------------------------------------------------------------
/components/cs/flow/atoms/fullAdder.ts:
--------------------------------------------------------------------------------
1 | import { PROPAGATION_DELAY_MS } from '@/components/cs/flow/constants';
2 | import type {
3 | NodeAtoms,
4 | NodeCreator,
5 | OutputAtom,
6 | OutputValue,
7 | } from '@/components/cs/flow/model/type';
8 | import { atom } from 'jotai';
9 | import { atomEffect } from 'jotai-effect';
10 |
11 | type FullAdderAtoms = NodeAtoms<'in1' | 'in2' | 'cin', 'sum' | 'cout', true>;
12 |
13 | export const createFullAdderAtoms: NodeCreator = (
14 | initialValues,
15 | ) => {
16 | const in1Atom = atom(null);
17 | const in2Atom = atom(null);
18 | const cinAtom = atom(null);
19 |
20 | const sumAtom = atom((get) => {
21 | const in1 = get(in1Atom);
22 | const in2 = get(in2Atom);
23 | const cin = get(cinAtom);
24 | if (in1 === null || in2 === null || cin === null) return null;
25 |
26 | const in1Value = get(in1);
27 | const in2Value = get(in2);
28 | const cinValue = get(cin);
29 | if (in1Value === null || in2Value === null || cinValue === null)
30 | return null;
31 |
32 | return Boolean(Number(in1Value) ^ Number(in2Value) ^ Number(cinValue));
33 | });
34 |
35 | const coutAtom = atom((get) => {
36 | const in1 = get(in1Atom);
37 | const in2 = get(in2Atom);
38 | const cin = get(cinAtom);
39 | if (in1 === null || in2 === null || cin === null) return null;
40 |
41 | const in1Value = get(in1);
42 | const in2Value = get(in2);
43 | const cinValue = get(cin);
44 | if (in1Value === null || in2Value === null || cinValue === null)
45 | return null;
46 |
47 | return Boolean(
48 | (in1Value && in2Value) ||
49 | (in2Value && cinValue) ||
50 | (cinValue && in1Value),
51 | );
52 | });
53 |
54 | const delayedSumAtom = atom(initialValues?.sum ?? null);
55 | const delayedCoutAtom = atom(initialValues?.cout ?? null);
56 |
57 | const outEffect = atomEffect((get, set) => {
58 | const sum = get(sumAtom);
59 | const cout = get(coutAtom);
60 |
61 | const id = setTimeout(() => {
62 | set.recurse(delayedSumAtom, sum);
63 | set.recurse(delayedCoutAtom, cout);
64 | }, PROPAGATION_DELAY_MS);
65 |
66 | return () => clearTimeout(id);
67 | });
68 |
69 | return {
70 | type: 'fullAdder' as const,
71 | inputAtoms: {
72 | in1: in1Atom,
73 | in2: in2Atom,
74 | cin: cinAtom,
75 | },
76 | outputAtoms: {
77 | sum: delayedSumAtom,
78 | cout: delayedCoutAtom,
79 | },
80 | effectAtom: outEffect,
81 | };
82 | };
83 |
--------------------------------------------------------------------------------
/components/cs/flow/atoms/halfAdder.ts:
--------------------------------------------------------------------------------
1 | import { PROPAGATION_DELAY_MS } from '@/components/cs/flow/constants';
2 | import type {
3 | NodeAtoms,
4 | NodeCreator,
5 | OutputAtom,
6 | OutputValue,
7 | } from '@/components/cs/flow/model/type';
8 | import { atom } from 'jotai';
9 | import { atomEffect } from 'jotai-effect';
10 |
11 | type HalfAdderAtoms = NodeAtoms<'in1' | 'in2', 'sum' | 'carry', true>;
12 |
13 | export const createHalfAdderAtoms: NodeCreator = (
14 | initialValues,
15 | ) => {
16 | const in1Atom = atom(null);
17 | const in2Atom = atom(null);
18 |
19 | const sumAtom = atom((get) => {
20 | const in1 = get(in1Atom);
21 | const in2 = get(in2Atom);
22 | if (in1 === null || in2 === null) return null;
23 |
24 | const in1Value = get(in1);
25 | const in2Value = get(in2);
26 | if (in1Value === null || in2Value === null) return null;
27 |
28 | return in1Value !== in2Value;
29 | });
30 |
31 | const carryAtom = atom((get) => {
32 | const in1 = get(in1Atom);
33 | const in2 = get(in2Atom);
34 | if (in1 === null || in2 === null) return null;
35 |
36 | const in1Value = get(in1);
37 | const in2Value = get(in2);
38 | if (in1Value === null || in2Value === null) return null;
39 |
40 | return Boolean(in1Value && in2Value);
41 | });
42 |
43 | const delayedSumAtom = atom(initialValues?.sum ?? null);
44 | const delayedCarryAtom = atom(initialValues?.carry ?? null);
45 |
46 | const outEffect = atomEffect((get, set) => {
47 | const sum = get(sumAtom);
48 | const carry = get(carryAtom);
49 |
50 | const id = setTimeout(() => {
51 | set.recurse(delayedSumAtom, sum);
52 | set.recurse(delayedCarryAtom, carry);
53 | }, PROPAGATION_DELAY_MS);
54 |
55 | return () => clearTimeout(id);
56 | });
57 |
58 | return {
59 | type: 'halfAdder' as const,
60 | inputAtoms: {
61 | in1: in1Atom,
62 | in2: in2Atom,
63 | },
64 | outputAtoms: {
65 | sum: delayedSumAtom,
66 | carry: delayedCarryAtom,
67 | },
68 | effectAtom: outEffect,
69 | };
70 | };
71 |
--------------------------------------------------------------------------------
/components/cs/flow/atoms/index.ts:
--------------------------------------------------------------------------------
1 | import { createFullAdderAtoms } from '@/components/cs/flow/atoms/fullAdder';
2 | import { createHalfAdderAtoms } from '@/components/cs/flow/atoms/halfAdder';
3 | import { createLabelAtoms } from '@/components/cs/flow/atoms/label';
4 | import { createNandAtoms } from '@/components/cs/flow/atoms/nand';
5 | import { createOrAtoms } from '@/components/cs/flow/atoms/or';
6 | import { createBooleanAtoms } from './boolean';
7 |
8 | export const registry = {
9 | number: createBooleanAtoms,
10 | nand: createNandAtoms,
11 | halfAdder: createHalfAdderAtoms,
12 | or: createOrAtoms,
13 | fullAdder: createFullAdderAtoms,
14 | label: createLabelAtoms,
15 | } as const;
16 |
17 | type Registry = typeof registry;
18 |
19 | export const registryKeys = [
20 | 'number',
21 | 'nand',
22 | 'halfAdder',
23 | 'or',
24 | 'fullAdder',
25 | 'label',
26 | ] satisfies (keyof Registry)[];
27 |
28 | export const registryNames = {
29 | number: '0/1',
30 | nand: 'NAND',
31 | halfAdder: 'Half Adder',
32 | or: 'OR',
33 | fullAdder: 'Full Adder',
34 | label: 'Label',
35 | } satisfies Record;
36 |
37 | export type RegistryKey = (typeof registryKeys)[number];
38 |
39 | export const isRegistryKey = (key: unknown): key is RegistryKey =>
40 | typeof key === 'string' && registryKeys.includes(key as RegistryKey);
41 |
42 | export type RegistryAtoms = ReturnType<
43 | Registry[T]
44 | >;
45 |
--------------------------------------------------------------------------------
/components/cs/flow/atoms/label.ts:
--------------------------------------------------------------------------------
1 | import { PROPAGATION_DELAY_MS } from '@/components/cs/flow/constants';
2 | import type {
3 | NodeAtoms,
4 | NodeCreator,
5 | OutputAtom,
6 | OutputValue,
7 | } from '@/components/cs/flow/model/type';
8 | import { atom } from 'jotai';
9 | import { atomEffect } from 'jotai-effect';
10 |
11 | type LabelAtoms = NodeAtoms<'in', 'label' | 'out', true>;
12 |
13 | export const createLabelAtoms: NodeCreator = (initialValues) => {
14 | const inAtom = atom(null);
15 |
16 | const labelAtom = atom(initialValues?.label ?? null);
17 | const outAtom = atom(initialValues?.out ?? null);
18 |
19 | const outEffect = atomEffect((get, set) => {
20 | const in1 = get(inAtom);
21 | console.log('in1', in1);
22 | if (in1 === null) return;
23 |
24 | const in1Value = get(in1);
25 | console.log('in1Value', in1Value);
26 |
27 | const id = setTimeout(() => {
28 | set.recurse(outAtom, in1Value);
29 | }, PROPAGATION_DELAY_MS);
30 |
31 | return () => clearTimeout(id);
32 | });
33 |
34 | return {
35 | type: 'label' as const,
36 | inputAtoms: {
37 | in: inAtom,
38 | },
39 | outputAtoms: {
40 | label: labelAtom,
41 | out: outAtom,
42 | },
43 | effectAtom: outEffect,
44 | };
45 | };
46 |
--------------------------------------------------------------------------------
/components/cs/flow/atoms/nand.ts:
--------------------------------------------------------------------------------
1 | import { PROPAGATION_DELAY_MS } from '@/components/cs/flow/constants';
2 | import type {
3 | NodeAtoms,
4 | NodeCreator,
5 | OutputAtom,
6 | OutputValue,
7 | } from '@/components/cs/flow/model/type';
8 | import { atom } from 'jotai';
9 | import { atomEffect } from 'jotai-effect';
10 |
11 | type NandAtoms = NodeAtoms<'in1' | 'in2', 'out', true>;
12 |
13 | export const createNandAtoms: NodeCreator = (initialValues) => {
14 | const in1Atom = atom(null);
15 | const in2Atom = atom(null);
16 |
17 | const nandAtom = atom((get) => {
18 | const in1 = get(in1Atom);
19 | const in2 = get(in2Atom);
20 | if (in1 === null || in2 === null) return null;
21 |
22 | const in1Value = get(in1);
23 | const in2Value = get(in2);
24 |
25 | // input 하나가 null인걸 의도적을 허용
26 | // 이를 통해 피드백 구조를 허용
27 | return !(in1Value && in2Value);
28 | });
29 |
30 | const outAtom = atom(initialValues?.out ?? null);
31 |
32 | const outEffect = atomEffect((get, set) => {
33 | const out = get(nandAtom);
34 |
35 | const id = setTimeout(() => {
36 | set.recurse(outAtom, out);
37 | }, PROPAGATION_DELAY_MS);
38 |
39 | return () => clearTimeout(id);
40 | });
41 |
42 | return {
43 | type: 'nand' as const,
44 | inputAtoms: {
45 | in1: in1Atom,
46 | in2: in2Atom,
47 | },
48 | outputAtoms: {
49 | out: outAtom,
50 | },
51 | effectAtom: outEffect,
52 | };
53 | };
54 |
--------------------------------------------------------------------------------
/components/cs/flow/atoms/or.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | NodeAtoms,
3 | NodeCreator,
4 | OutputAtom,
5 | OutputValue,
6 | } from '@/components/cs/flow/model/type';
7 | import { atom } from 'jotai';
8 | import { atomEffect } from 'jotai-effect';
9 |
10 | type OrAtoms = NodeAtoms<'in1' | 'in2', 'out', true>;
11 |
12 | export const createOrAtoms: NodeCreator = (initialValues) => {
13 | const in1Atom = atom(null);
14 | const in2Atom = atom(null);
15 |
16 | const orAtom = atom((get) => {
17 | const in1 = get(in1Atom);
18 | const in2 = get(in2Atom);
19 |
20 | if (in1 === null || in2 === null) {
21 | return null;
22 | }
23 |
24 | const in1Value = get(in1);
25 | const in2Value = get(in2);
26 |
27 | return Boolean(in1Value || in2Value);
28 | });
29 |
30 | const outAtom = atom(initialValues?.out ?? null);
31 |
32 | const outEffect = atomEffect((get, set) => {
33 | const out = get(orAtom);
34 |
35 | const id = setTimeout(() => {
36 | set.recurse(outAtom, out);
37 | }, 200);
38 |
39 | return () => clearTimeout(id);
40 | });
41 |
42 | return {
43 | type: 'or' as const,
44 | inputAtoms: {
45 | in1: in1Atom,
46 | in2: in2Atom,
47 | },
48 | outputAtoms: {
49 | out: outAtom,
50 | },
51 | effectAtom: outEffect,
52 | };
53 | };
54 |
--------------------------------------------------------------------------------
/components/cs/flow/components/BooleanNode.tsx:
--------------------------------------------------------------------------------
1 | import NameTag from '@/components/cs/flow/components/NameTag';
2 | import type { NodeProps } from '@/components/cs/flow/components/type';
3 | import { RIGHT_HANDLE_STYLE } from '@/components/cs/flow/constants';
4 | import { Handle, Position } from '@xyflow/react';
5 | import clsx from 'clsx';
6 | import { useAtom } from 'jotai';
7 |
8 | export const BooleanNode = (props: NodeProps<'number'>) => {
9 | const { atoms } = props.data;
10 | const [out, setOut] = useAtom(atoms.outputAtoms.out);
11 |
12 | return (
13 |
14 |
setOut(!out)}
21 | type="button"
22 | >
23 | {out ? 1 : 0}
24 |
30 |
31 |
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/components/cs/flow/components/FullAdderNode.tsx:
--------------------------------------------------------------------------------
1 | import type { NodeProps } from '@/components/cs/flow/components/type';
2 | import {
3 | LEFT_HANDLE_STYLE,
4 | RIGHT_HANDLE_STYLE,
5 | } from '@/components/cs/flow/constants';
6 | import type { OutputValue } from '@/components/cs/flow/model/type';
7 | import { Handle, Position, useNodeConnections } from '@xyflow/react';
8 | import clsx from 'clsx';
9 | import { useAtom, useAtomValue } from 'jotai';
10 |
11 | export const FullAdderNode = (props: NodeProps<'fullAdder'>) => {
12 | const { atoms } = props.data;
13 |
14 | const sum = useAtomValue(atoms.outputAtoms.sum);
15 | const cout = useAtomValue(atoms.outputAtoms.cout);
16 | useAtom(atoms.effectAtom);
17 |
18 | const connections = useNodeConnections();
19 |
20 | // 최적화 필요
21 | // 애초에 useConnection에서 나에게로 오는 것만 반환하게
22 | const in1Connections = connections.filter(
23 | (connection) =>
24 | connection.target === props.id && connection.targetHandle === 'in1',
25 | );
26 | const in2Connections = connections.filter(
27 | (connection) =>
28 | connection.target === props.id && connection.targetHandle === 'in2',
29 | );
30 | const cinConnections = connections.filter(
31 | (connection) =>
32 | connection.target === props.id && connection.targetHandle === 'cin',
33 | );
34 |
35 | const valueToColor = (value: OutputValue | null) => {
36 | if (value === null) return 'text-white';
37 | return value ? 'text-green-500' : 'text-red-500';
38 | };
39 |
40 | return (
41 |
47 |
54 |
61 |
68 |
69 |
75 |
81 |
82 |
83 | CIN
84 |
85 |
86 | IN1
87 |
88 |
89 | IN2
90 |
91 |
92 |
98 | SUM
99 |
100 |
106 | CARRY
107 |
108 |
109 | FULL ADDER
110 |
111 |
112 | );
113 | };
114 |
--------------------------------------------------------------------------------
/components/cs/flow/components/HalfAdderNode.tsx:
--------------------------------------------------------------------------------
1 | import type { NodeProps } from '@/components/cs/flow/components/type';
2 | import {
3 | LEFT_HANDLE_STYLE,
4 | RIGHT_HANDLE_STYLE,
5 | } from '@/components/cs/flow/constants';
6 | import type { OutputValue } from '@/components/cs/flow/model/type';
7 | import { Handle, Position, useNodeConnections } from '@xyflow/react';
8 | import clsx from 'clsx';
9 | import { useAtom, useAtomValue } from 'jotai';
10 |
11 | export const HalfAdderNode = (props: NodeProps<'halfAdder'>) => {
12 | const { atoms } = props.data;
13 |
14 | const sum = useAtomValue(atoms.outputAtoms.sum);
15 | const carry = useAtomValue(atoms.outputAtoms.carry);
16 | useAtom(atoms.effectAtom);
17 |
18 | const connections = useNodeConnections();
19 |
20 | // 최적화 필요
21 | // 애초에 useConnection에서 나에게로 오는 것만 반환하게
22 | const in1Connections = connections.filter(
23 | (connection) =>
24 | connection.target === props.id && connection.targetHandle === 'in1',
25 | );
26 | const in2Connections = connections.filter(
27 | (connection) =>
28 | connection.target === props.id && connection.targetHandle === 'in2',
29 | );
30 |
31 | const valueToColor = (value: OutputValue | null) => {
32 | if (value === null) return 'text-white';
33 | return value ? 'text-green-500' : 'text-red-500';
34 | };
35 |
36 | return (
37 |
43 |
50 |
57 |
63 |
69 |
70 |
76 | SUM
77 |
78 |
84 | CARRY
85 |
86 |
87 | HALF ADDER
88 |
89 |
90 | );
91 | };
92 |
--------------------------------------------------------------------------------
/components/cs/flow/components/LabelNode.tsx:
--------------------------------------------------------------------------------
1 | import NameTag from '@/components/cs/flow/components/NameTag';
2 | import type { NodeProps } from '@/components/cs/flow/components/type';
3 | import { LEFT_HANDLE_STYLE } from '@/components/cs/flow/constants';
4 | import { Handle, Position, useNodeConnections } from '@xyflow/react';
5 | import clsx from 'clsx';
6 | import { useAtom, useAtomValue } from 'jotai';
7 |
8 | export const LabelNode = (props: NodeProps<'label'>) => {
9 | const { atoms } = props.data;
10 |
11 | const out = useAtomValue(atoms.outputAtoms.out);
12 | useAtom(atoms.effectAtom);
13 |
14 | const connections = useNodeConnections();
15 |
16 | // 최적화 필요
17 | // 애초에 useConnection에서 나에게로 오는 것만 반환하게
18 | const inConnection = connections.filter(
19 | (connection) =>
20 | connection.target === props.id && connection.targetHandle === 'in1',
21 | );
22 |
23 | const backgroundColor = (() => {
24 | switch (out) {
25 | case null:
26 | return '';
27 | case false:
28 | return 'bg-red-500';
29 | case true:
30 | return 'bg-green-500';
31 | }
32 | })();
33 |
34 | return (
35 |
42 |
52 | {/* 생긴게 치우치게 생겨서 중심 미세조정 */}
53 |
54 | {out === true ? 1 : out === false ? 0 : ''}
55 |
56 |
57 |
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/components/cs/flow/components/NameTag.tsx:
--------------------------------------------------------------------------------
1 | import type { OutputAtom, OutputValue } from '@/components/cs/flow/model/type';
2 | import { useAtom } from 'jotai';
3 | import { Tag } from 'lucide-react';
4 | import { useState } from 'react';
5 |
6 | export default function NameTag({ atom }: { atom: OutputAtom }) {
7 | const [label, setLabel] = useAtom(atom);
8 | const [isEditing, setIsEditing] = useState(false);
9 |
10 | const handleLabelChange = (val: OutputValue) => {
11 | if (typeof val === 'string') {
12 | setLabel(val);
13 | setIsEditing(false);
14 | }
15 | };
16 |
17 | return (
18 |
19 | {isEditing ? (
20 | handleLabelChange(e.target.value)}
23 | onKeyDown={(e) => {
24 | if (e.key === 'Enter') {
25 | handleLabelChange(e.currentTarget.value);
26 | }
27 | }}
28 | className="h-4 text-xs bg-white text-black px-1 border-none outline-none w-full"
29 | // biome-ignore lint/a11y/noAutofocus:
30 | autoFocus
31 | />
32 | ) : (
33 | {
35 | setIsEditing(true);
36 | }}
37 | type="button"
38 | className="cursor-pointer shrink-0"
39 | >
40 | {label ? (
41 |
42 | {label.toString()}
43 |
44 | ) : (
45 |
46 | )}
47 |
48 | )}
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/components/cs/flow/components/NandNode.tsx:
--------------------------------------------------------------------------------
1 | import type { NodeProps } from '@/components/cs/flow/components/type';
2 | import {
3 | LEFT_HANDLE_STYLE,
4 | RIGHT_HANDLE_STYLE,
5 | } from '@/components/cs/flow/constants';
6 | import { Handle, Position, useNodeConnections } from '@xyflow/react';
7 | import clsx from 'clsx';
8 | import { useAtom, useAtomValue } from 'jotai';
9 |
10 | export const NandNode = (props: NodeProps<'nand'>) => {
11 | const { atoms } = props.data;
12 |
13 | const out = useAtomValue(atoms.outputAtoms.out);
14 | useAtom(atoms.effectAtom);
15 |
16 | const connections = useNodeConnections();
17 |
18 | // 최적화 필요
19 | // 애초에 useConnection에서 나에게로 오는 것만 반환하게
20 | const in1Connections = connections.filter(
21 | (connection) =>
22 | connection.target === props.id && connection.targetHandle === 'in1',
23 | );
24 | const in2Connections = connections.filter(
25 | (connection) =>
26 | connection.target === props.id && connection.targetHandle === 'in2',
27 | );
28 |
29 | const backgroundColor = (() => {
30 | switch (out) {
31 | case null:
32 | return '';
33 | case false:
34 | return 'bg-red-500';
35 | case true:
36 | return 'bg-green-500';
37 | }
38 | })();
39 |
40 | return (
41 |
48 |
54 |
64 |
74 |
84 | {/* 생긴게 치우치게 생겨서 중심 미세조정 */}
85 |
86 | {out === true ? 1 : out === false ? 0 : ''}
87 |
88 |
89 | );
90 | };
91 |
--------------------------------------------------------------------------------
/components/cs/flow/components/OrNode.tsx:
--------------------------------------------------------------------------------
1 | import type { NodeProps } from '@/components/cs/flow/components/type';
2 | import {
3 | LEFT_HANDLE_STYLE,
4 | RIGHT_HANDLE_STYLE,
5 | } from '@/components/cs/flow/constants';
6 | import { Handle, Position, useNodeConnections } from '@xyflow/react';
7 | import { useAtom, useAtomValue } from 'jotai';
8 |
9 | export const OrNode = ({ id, data, selected }: NodeProps<'or'>) => {
10 | const { atoms } = data;
11 |
12 | const out = useAtomValue(atoms.outputAtoms.out);
13 | useAtom(atoms.effectAtom);
14 |
15 | const connections = useNodeConnections();
16 |
17 | // 최적화 필요
18 | // 애초에 useConnection에서 나에게로 오는 것만 반환하게
19 | const in1Connections = connections.filter(
20 | (connection) =>
21 | connection.target === id && connection.targetHandle === 'in1',
22 | );
23 | const in2Connections = connections.filter(
24 | (connection) =>
25 | connection.target === id && connection.targetHandle === 'in2',
26 | );
27 |
28 | const backgroundColor = (() => {
29 | switch (out) {
30 | case null:
31 | // svg라서 그런가 명시 안하니까 검은색이 되네
32 | return 'fill-transparent';
33 | case false:
34 | return 'fill-red-500';
35 | case true:
36 | return 'fill-green-500';
37 | }
38 | })();
39 |
40 | return (
41 |
42 |
58 |
68 |
78 |
87 |
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/components/cs/flow/components/index.tsx:
--------------------------------------------------------------------------------
1 | import type { RegistryKey } from '@/components/cs/flow/atoms';
2 | import { BooleanNode } from '@/components/cs/flow/components/BooleanNode';
3 | import { FullAdderNode } from '@/components/cs/flow/components/FullAdderNode';
4 | import { HalfAdderNode } from '@/components/cs/flow/components/HalfAdderNode';
5 | import { LabelNode } from '@/components/cs/flow/components/LabelNode';
6 | import { NandNode } from '@/components/cs/flow/components/NandNode';
7 | import { OrNode } from '@/components/cs/flow/components/OrNode';
8 | import type { ComponentType } from 'react';
9 | import type { NodeProps } from './type';
10 |
11 | export const nodeTypes = {
12 | number: BooleanNode,
13 | nand: NandNode,
14 | halfAdder: HalfAdderNode,
15 | or: OrNode,
16 | fullAdder: FullAdderNode,
17 | label: LabelNode,
18 | } satisfies Record>>;
19 |
--------------------------------------------------------------------------------
/components/cs/flow/components/type.ts:
--------------------------------------------------------------------------------
1 | import type { RegistryAtoms, RegistryKey } from '@/components/cs/flow/atoms';
2 |
3 | export type NodeProps = {
4 | id: string;
5 | data: { atoms: RegistryAtoms };
6 | selected: boolean;
7 | };
8 |
--------------------------------------------------------------------------------
/components/cs/flow/constants.ts:
--------------------------------------------------------------------------------
1 | export const PROPAGATION_DELAY_MS = 200;
2 |
3 | export const RIGHT_HANDLE_STYLE = {
4 | border: '1px solid white',
5 | width: '16px',
6 | height: '16px',
7 | backgroundColor: 'transparent',
8 | transform: 'translateX(100%) translateY(-50%)',
9 | };
10 |
11 | export const LEFT_HANDLE_STYLE = {
12 | border: '1px dashed white',
13 | width: '16px',
14 | height: '16px',
15 | backgroundColor: 'transparent',
16 | transform: 'translateX(-100%) translateY(-50%)',
17 | };
18 |
--------------------------------------------------------------------------------
/components/cs/flow/hooks/useMobileState.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useState } from 'react';
2 |
3 | export type TouchDeviceState =
4 | | {
5 | type: 'loading';
6 | }
7 | | {
8 | type: 'desktop';
9 | }
10 | | {
11 | type: 'mobile';
12 | value: boolean;
13 | };
14 |
15 | export const useTouchDeviceState = () => {
16 | const [state, setState] = useState({ type: 'loading' });
17 |
18 | useLayoutEffect(() => {
19 | if (typeof window === 'undefined') return;
20 | const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
21 | setState(isTouch ? { type: 'mobile', value: false } : { type: 'desktop' });
22 | }, []);
23 |
24 | return [state, setState] as const;
25 | };
26 |
--------------------------------------------------------------------------------
/components/cs/flow/model/type.ts:
--------------------------------------------------------------------------------
1 | import type { Edge, Node, ReactFlowJsonObject } from '@xyflow/react';
2 | import type { Atom, PrimitiveAtom, createStore } from 'jotai';
3 |
4 | export type JotaiStore = ReturnType;
5 |
6 | export type NodeCreator = (
7 | initialValues?: {
8 | [key in keyof T['outputAtoms']]: boolean;
9 | },
10 | ) => Omit;
11 |
12 | // 출력을 derived atom이 아닌 별도 상태로 관리
13 | // 사이클 방지 + 딜레이 구현을 위함
14 | export type OutputValue =
15 | // true/false
16 | | boolean
17 | // 4bit 8bit 16bit...
18 | | number
19 | // 라벨 처리 귀찮아서...
20 | // InputAtom에서 가려서 받아야할까
21 | | string
22 | // 미정
23 | | null;
24 |
25 | export type OutputAtom = PrimitiveAtom;
26 | export type InputAtom = PrimitiveAtom;
27 |
28 | // 특정 노드의 상태를 표현
29 | export type NodeAtoms<
30 | InputKeys extends string | never = string | never,
31 | OutputKeys extends string | never = string | never,
32 | HasEffect extends boolean = boolean,
33 | > = {
34 | id: string;
35 | inputAtoms: { [key in InputKeys]: InputAtom };
36 | outputAtoms: { [key in OutputKeys]: OutputAtom };
37 | // 사이클 방지 + 딜레이 구현을 위함
38 | effectAtom: HasEffect extends true ? Atom : undefined;
39 | };
40 |
41 | export type NodeOutput = Record;
42 |
43 | export type NodeOutputs = Record;
44 |
45 | export type SaveFile = ReactFlowJsonObject & {
46 | nodeOutputs: NodeOutputs;
47 | };
48 |
--------------------------------------------------------------------------------
/components/cs/transistor.css:
--------------------------------------------------------------------------------
1 | @keyframes flowUp {
2 | 0% {
3 | transform: translateY(0);
4 | opacity: 0;
5 | }
6 | 20% {
7 | opacity: 1;
8 | }
9 | 80% {
10 | opacity: 1;
11 | }
12 | 100% {
13 | transform: translateY(-1.5rem);
14 | opacity: 0;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/components/layout/Footer.tsx:
--------------------------------------------------------------------------------
1 | import LocaleSwitcher from '@/components/layout/LocaleSwitcher';
2 | import { Link } from '@/i18n/navigation';
3 | import { useTranslations } from 'next-intl';
4 |
5 | export default function Footer() {
6 | const t = useTranslations('Footer');
7 |
8 | return (
9 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/components/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import Button from '@/components/ui/Button';
3 | import { Link } from '@/i18n/navigation';
4 | import { useSessionStore } from '@/store/session';
5 | import { useProfile } from '@/swr/auth';
6 | import { Github, LogOut } from 'lucide-react';
7 | import { useTranslations } from 'next-intl';
8 |
9 | export default function Header() {
10 | const t = useTranslations('Header');
11 | const { session, isLoading, login, logout } = useSessionStore();
12 | const { data: profile } = useProfile();
13 |
14 | return (
15 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/components/layout/LocaleSwitcher.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname, useRouter } from '@/i18n/navigation';
4 | import { routing } from '@/i18n/routing';
5 | import clsx from 'clsx';
6 | import { useLocale, useTranslations } from 'next-intl';
7 | import type { Locale } from 'next-intl';
8 | import { useParams } from 'next/navigation';
9 | import { ToggleGroup } from 'radix-ui';
10 |
11 | export default function LocaleSwitcher() {
12 | const t = useTranslations('LocaleSwitcher');
13 | const locale = useLocale();
14 | const router = useRouter();
15 | const pathname = usePathname();
16 | const params = useParams();
17 |
18 | function onValueChange(nextLocale: string) {
19 | if (!nextLocale) return;
20 |
21 | router.replace(
22 | // @ts-expect-error -- TypeScript will validate that only known `params`
23 | // are used in combination with a given `pathname`. Since the two will
24 | // always match for the current route, we can skip runtime checks.
25 | { pathname, params },
26 | { locale: nextLocale as Locale },
27 | );
28 | }
29 |
30 | return (
31 |
32 |
{t('label')}
33 |
39 | {routing.locales.map((cur) => (
40 |
49 | {t('locale', { locale: cur })}
50 |
51 | ))}
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/components/layout/TableOfContents.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Link } from '@/i18n/navigation';
4 | import { useTranslations } from 'next-intl';
5 | import { ScrollArea } from 'radix-ui';
6 | import { useEffect, useState } from 'react';
7 |
8 | interface TOCItem {
9 | id: string;
10 | text: string;
11 | level: number;
12 | }
13 |
14 | export default function TableOfContents() {
15 | const { headings, activeId } = useTableOfContents();
16 | const t = useTranslations('TableOfContents');
17 |
18 | if (headings.length === 0) return null;
19 |
20 | return (
21 |
22 |
23 |
45 |
46 |
50 |
51 |
52 |
53 | );
54 | }
55 |
56 | function useTableOfContents() {
57 | const [headings, setHeadings] = useState([]);
58 | const [activeId, setActiveId] = useState('');
59 |
60 | useEffect(() => {
61 | const headingElements = Array.from(
62 | document.querySelectorAll('h2, h3, h4, h5, h6'),
63 | );
64 |
65 | const items = headingElements.map((heading) => {
66 | const level = Number.parseInt(heading.tagName.substring(1));
67 | const id = heading.id || '';
68 | const text = heading.textContent || '';
69 |
70 | return { id, text, level };
71 | });
72 |
73 | setHeadings(items);
74 |
75 | const observer = new IntersectionObserver(
76 | (entries) => {
77 | for (const entry of entries) {
78 | if (entry.isIntersecting) {
79 | setActiveId(entry.target.id);
80 | break;
81 | }
82 | }
83 | },
84 | { rootMargin: '0px 0px -80% 0px' },
85 | );
86 |
87 | for (const element of headingElements) {
88 | observer.observe(element);
89 | }
90 |
91 | return () => observer.disconnect();
92 | }, []);
93 |
94 | return { headings, activeId };
95 | }
96 |
--------------------------------------------------------------------------------
/components/mdx/Image.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import mediumZoom from 'medium-zoom/dist/pure';
3 | import NextImage from 'next/image';
4 |
5 | export default function Image({
6 | className,
7 | ...props
8 | }: React.ComponentProps) {
9 | return (
10 | {
14 | if (!ref) return;
15 | const zoom = mediumZoom(ref, {
16 | background: 'rgba(0, 0, 0, 0.7)',
17 | scrollOffset: 20,
18 | });
19 | return () => {
20 | zoom.detach();
21 | };
22 | }}
23 | />
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/components/mdx/Pre.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Button from '@/components/ui/Button';
4 | import clsx from 'clsx';
5 | import { debounce } from 'es-toolkit';
6 | import { CheckIcon, CopyIcon } from 'lucide-react';
7 | import { useCallback, useRef, useState } from 'react';
8 |
9 | export default function Pre({
10 | children,
11 | ...props
12 | }: React.ComponentProps<'pre'>) {
13 | const ref = useRef(null);
14 | const [isCopied, setIsCopied] = useState(false);
15 | const reset = useCallback(
16 | debounce(() => setIsCopied(false), 1000),
17 | [],
18 | );
19 |
20 | return (
21 |
22 | {
27 | navigator.clipboard.writeText(ref.current?.textContent ?? '');
28 | setIsCopied(true);
29 | reset();
30 | }}
31 | />
32 | {children}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/components/meme/MemeCard.tsx:
--------------------------------------------------------------------------------
1 | import TagCheckbox from '@/components/meme/TagCheckbox';
2 | import Button from '@/components/ui/Button';
3 | import { Checkbox } from '@/components/ui/Checkbox';
4 | import Form from '@/components/ui/Form';
5 | import { deleteMemeFromDB } from '@/db/meme/delete';
6 | import { memesByTagKey } from '@/swr/key';
7 | import { NO_TAG_ID, updateMeme, useMemeTags, useTags } from '@/swr/meme';
8 | import type { Meme } from '@/types/helper.types';
9 | import clsx from 'clsx';
10 | import { Save, Trash2 } from 'lucide-react';
11 | import { useReducer } from 'react';
12 | import { mutate } from 'swr/_internal';
13 |
14 | export type MemeCardProps = Meme;
15 |
16 | const MemeCard = ({ data: meme }: { data: MemeCardProps }) => {
17 | const [isEdit, toggleIsEdit] = useReducer((prev) => !prev, false);
18 | const { data: tags } = useTags();
19 | const { data: memeTags } = useMemeTags(meme.id);
20 |
21 | return (
22 | <>
23 |
28 |
34 | {meme.title}
35 |
36 |
37 | {isEdit && (
38 |
57 | tag.tag_id ?? '') ?? []}
61 | />
62 |
63 |
64 | 숨김
65 |
66 |
67 |
68 |
69 | {
74 | if (confirm('정말 삭제하시겠습니까?')) {
75 | await deleteMemeFromDB(meme.id);
76 | mutate(memesByTagKey(NO_TAG_ID));
77 | for (const tag of memeTags ?? []) {
78 | // TODO: if 필요한가?
79 | if (tag.tag_id) mutate(memesByTagKey(tag.tag_id));
80 | }
81 | }
82 | }}
83 | >
84 | 삭제
85 |
86 |
87 | 저장
88 |
89 |
90 |
91 | )}
92 | >
93 | );
94 | };
95 |
96 | export default MemeCard;
97 |
--------------------------------------------------------------------------------
/components/meme/Tag.tsx:
--------------------------------------------------------------------------------
1 | import type { InputHTMLAttributes } from 'react';
2 |
3 | export default function Tag({
4 | label,
5 | id,
6 | ...inputProps
7 | }: InputHTMLAttributes & { label: string }) {
8 | return (
9 |
10 |
15 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/components/meme/TagCheckbox.tsx:
--------------------------------------------------------------------------------
1 | import Tag from '@/components/meme/Tag';
2 | import type { Tag as TagType } from '@/types/helper.types';
3 |
4 | const TagCheckbox = ({
5 | tags,
6 | name,
7 | initialValues,
8 | }: {
9 | tags: Pick[];
10 | name: string;
11 | initialValues: string[] | null;
12 | }) => {
13 | return (
14 |
15 | {tags.map((tag) => (
16 |
25 | ))}
26 |
27 | );
28 | };
29 |
30 | export default TagCheckbox;
31 |
--------------------------------------------------------------------------------
/components/meme/TagRadio.tsx:
--------------------------------------------------------------------------------
1 | import Tag from '@/components/meme/Tag';
2 | import { Label } from '@/components/ui/Form';
3 | import type { Tag as TagType } from '@/types/helper.types';
4 |
5 | const TagRadio = ({
6 | tags,
7 | name,
8 | initialValue,
9 | }: {
10 | tags: Pick[];
11 | name: string;
12 | initialValue: string | null;
13 | }) => {
14 | return (
15 |
16 |
17 |
18 | {tags.map((tag) => (
19 |
28 | ))}
29 |
30 |
31 | );
32 | };
33 |
34 | export default TagRadio;
35 |
--------------------------------------------------------------------------------
/components/ui/Button.tsx:
--------------------------------------------------------------------------------
1 | import { type Bg, bgMap } from '@/components/ui/theme';
2 | import clsx from 'clsx';
3 | import { Loader2, type LucideProps } from 'lucide-react';
4 | import type { ButtonHTMLAttributes, ReactNode } from 'react';
5 |
6 | // https://github.com/radix-ui/primitives/issues/892
7 | const Button = ({
8 | bg: theme,
9 | onClick,
10 | children,
11 | Icon,
12 | className,
13 | isLoading,
14 | ...props
15 | }: {
16 | bg: Bg;
17 | onClick?: () => void;
18 | Icon: (props: LucideProps) => ReactNode;
19 | isLoading?: boolean;
20 | ref?: React.RefObject;
21 | } & ButtonHTMLAttributes) => {
22 | return (
23 |
34 | {isLoading ? (
35 |
36 | ) : (
37 |
38 | )}
39 | {children}
40 |
41 | );
42 | };
43 |
44 | export default Button;
45 |
--------------------------------------------------------------------------------
/components/ui/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { bgMap } from '@/components/ui/theme';
2 | import clsx from 'clsx';
3 | import { CheckIcon } from 'lucide-react';
4 | import { Checkbox as CheckboxPrimitive } from 'radix-ui';
5 | import type * as React from 'react';
6 |
7 | // radix-ui의 프롭과 동일하게 사용
8 | const Checkbox = ({
9 | className,
10 | ...props
11 | }: CheckboxPrimitive.CheckboxProps &
12 | React.RefAttributes) => (
13 |
21 |
22 |
23 |
24 |
25 | );
26 |
27 | export { Checkbox };
28 |
--------------------------------------------------------------------------------
/components/ui/Form.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { Clipboard, Keyboard } from 'lucide-react';
3 | import type {
4 | FormHTMLAttributes,
5 | InputHTMLAttributes,
6 | LabelHTMLAttributes,
7 | ReactNode,
8 | } from 'react';
9 | import { useRef } from 'react';
10 | import { toast } from 'react-toastify';
11 |
12 | export const Label = ({
13 | className,
14 | htmlFor,
15 | ...props
16 | }: LabelHTMLAttributes &
17 | Required, 'htmlFor'>>) => {
18 | return (
19 | // biome-ignore lint/a11y/noLabelWithoutControl: 왜뜨지
20 |
29 | );
30 | };
31 |
32 | const LabelGroup = ({ children }: { children: ReactNode }) => {
33 | return {children}
;
34 | };
35 |
36 | export const Input = ({
37 | className,
38 | ...props
39 | }: InputHTMLAttributes) => {
40 | return (
41 |
46 |
47 |
51 |
52 | );
53 | };
54 |
55 | const Text = ({
56 | title,
57 | ...inputProps
58 | }: {
59 | title: string;
60 | } & InputHTMLAttributes) => {
61 | return (
62 |
63 |
64 |
65 |
66 | );
67 | };
68 |
69 | export const ImageUploader = ({
70 | title = '이미지 첨부',
71 | ...inputProps
72 | }: {
73 | title?: string;
74 | } & InputHTMLAttributes &
75 | Required, 'name'>>) => {
76 | const inputRef = useRef(null);
77 |
78 | const handlePaste = async (e?: ClipboardEvent) => {
79 | if (!inputRef.current) return;
80 |
81 | const clipboardItems = await navigator.clipboard.read();
82 |
83 | for (const item of clipboardItems) {
84 | if (item.types.some((type) => type.startsWith('image/'))) {
85 | const blob = await item.getType('image/png');
86 |
87 | // 이게 최선인지...
88 | // https://stackoverflow.com/questions/47119426/how-to-set-file-objects-and-length-property-at-filelist-object-where-the-files-a
89 |
90 | // TODO: 그냥 input type="file"을 안쓰는건 어떠려나
91 | const dt = new DataTransfer();
92 | dt.items.add(
93 | new File([blob], 'pasted-image.png', { type: 'image/png' }),
94 | );
95 |
96 | inputRef.current.files = dt.files;
97 | inputRef.current.dispatchEvent(new Event('change', { bubbles: true }));
98 |
99 | return;
100 | }
101 | }
102 | toast.error('이미지 붙여넣기 실패');
103 | };
104 |
105 | return (
106 |
107 |
108 |
109 |
116 | handlePaste()}
119 | className="bg-stone-700 p-2 text-white hover:bg-stone-600"
120 | title="클립보드에서 붙여넣기"
121 | >
122 |
123 |
124 |
125 |
126 | );
127 | };
128 |
129 | const Form = ({
130 | children,
131 | ...props
132 | }: FormHTMLAttributes & { children: React.ReactNode }) => {
133 | return (
134 |
137 | );
138 | };
139 |
140 | export default Object.assign(Form, { Text, Label, Image: ImageUploader });
141 |
--------------------------------------------------------------------------------
/components/ui/Slider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Slider as RadixSlider } from 'radix-ui';
4 | import type { ReactNode } from 'react';
5 |
6 | interface SliderProps {
7 | value: number;
8 | onValueChange: (value: number) => void;
9 | min?: number;
10 | max?: number;
11 | step?: number;
12 | ariaLabel?: string;
13 | ariaLabelledby?: string;
14 | label?: ReactNode;
15 | decorator?: ReactNode;
16 | className?: string;
17 | trackClassName?: string;
18 | rangeClassName?: string;
19 | thumbClassName?: string;
20 | }
21 |
22 | export default function Slider({
23 | value,
24 | onValueChange,
25 | min = 0,
26 | max = 100,
27 | step = 1,
28 | ariaLabel,
29 | ariaLabelledby,
30 | label,
31 | decorator,
32 | className = '',
33 | trackClassName = '',
34 | rangeClassName = '',
35 | thumbClassName = '',
36 | }: SliderProps) {
37 | return (
38 |
39 | {label && (
40 |
41 | {label}
42 |
43 | )}
44 | onValueChange(val)}
48 | max={max}
49 | min={min}
50 | step={step}
51 | aria-label={ariaLabel}
52 | aria-labelledby={ariaLabelledby}
53 | >
54 |
57 |
60 |
61 |
65 |
66 | {decorator}
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/components/ui/theme.ts:
--------------------------------------------------------------------------------
1 | export const bgMap = {
2 | green: 'bg-[#4CAF50]',
3 | gray: 'bg-stone-700 hover:bg-stone-800 active:bg-stone-900 disabled:bg-stone-700',
4 | red: 'bg-red-500',
5 | transparent: 'bg-transparent hover:text-white/80 active:text-white/60',
6 | };
7 |
8 | export const border = 'outline-stone-700 outline';
9 |
10 | export type Bg = keyof typeof bgMap;
11 |
12 | export const skewOnHover =
13 | 'transition-transform group-hover:-skew-x-15 group-active:-skew-x-25 ease-in-out duration-200';
14 |
15 | export const layerBg = 'bg-stone-800';
16 |
17 | export const successBg = 'bg-green-500/30';
18 | export const failBg = 'bg-red-500/30';
19 |
--------------------------------------------------------------------------------
/db/auth.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 |
3 | export const loginToDB = async () => {
4 | const { error } = await supabase.auth.signInWithOAuth({
5 | provider: 'github',
6 | options: {
7 | redirectTo: `${window.location.origin}${window.location.pathname}?scrollY=${window.scrollY.toString()}`,
8 | },
9 | });
10 | if (error) throw error;
11 | };
12 |
13 | export const logoutFromDB = async () => {
14 | const { error } = await supabase.auth.signOut();
15 | if (error) throw error;
16 | };
17 |
18 | export async function getProfileFromDB() {
19 | const {
20 | data: { user },
21 | error: userError,
22 | } = await supabase.auth.getUser();
23 |
24 | if (userError) throw userError;
25 | if (!user) return null;
26 |
27 | const { data: profile } = await supabase
28 | .from('profiles')
29 | .select()
30 | .eq('id', user.id)
31 | .single()
32 | .throwOnError();
33 |
34 | // TODO
35 | if (profile.role === null) throw new Error('Role 값을 찾을 수 없습니다.');
36 |
37 | return profile;
38 | }
39 |
--------------------------------------------------------------------------------
/db/comment/create.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 |
3 | export async function createCommentInDB(
4 | postId: string,
5 | content: string,
6 | userId: string,
7 | ) {
8 | await supabase
9 | .from('comments')
10 | .insert({
11 | post_id: postId,
12 | content,
13 | author_id: userId,
14 | })
15 | .throwOnError();
16 | }
17 |
--------------------------------------------------------------------------------
/db/comment/delete.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 |
3 | export async function deleteCommentFromDB(commentId: string) {
4 | await supabase.from('comments').delete().eq('id', commentId).throwOnError();
5 | }
6 |
--------------------------------------------------------------------------------
/db/comment/read.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 | import { useTempUserStore } from '@/store/tempUser';
3 | import type { Session } from '@supabase/supabase-js';
4 |
5 | export async function getCommentsFromDB(postId: string) {
6 | const { data: comments } = await supabase
7 | .rpc('get_comments_with_developer_number', { post_id_param: postId })
8 | .throwOnError();
9 |
10 | return comments;
11 | }
12 |
13 | export const getEmojiCounts = async (
14 | postId: string,
15 | session: Session | null | undefined,
16 | ) => {
17 | const userId = session?.user?.id ?? useTempUserStore.getState().getId();
18 |
19 | const { data } = await supabase
20 | .rpc('get_emoji_counts', {
21 | p_post_id: postId,
22 | p_user_id: userId,
23 | })
24 | .throwOnError();
25 |
26 | return data;
27 | };
28 |
--------------------------------------------------------------------------------
/db/comment/update.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 | import { useTempUserStore } from '@/store/tempUser';
3 | import type { Session } from '@supabase/supabase-js';
4 |
5 | export async function addEmojiReactionInDB({
6 | postId,
7 | emoji,
8 | session,
9 | }: {
10 | postId: string;
11 | emoji: string;
12 | session: Session | null | undefined;
13 | }) {
14 | try {
15 | const tempId = useTempUserStore.getState().getId();
16 | const userId = session?.user?.id || tempId;
17 | const { data } = await supabase
18 | .rpc('add_emoji_reaction', {
19 | p_post_id: postId,
20 | p_emoji: emoji,
21 | p_user_id: userId,
22 | })
23 | .throwOnError();
24 |
25 | return { success: true, value: data };
26 | } catch (error) {
27 | console.error(error);
28 | return {
29 | success: false,
30 | message:
31 | error instanceof Error ? error.message : 'Unknown error occurred',
32 | };
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/db/index.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from '@/types/database.types';
2 | import { createClient } from '@supabase/supabase-js';
3 |
4 | const supabase = createClient(
5 | // biome-ignore lint/style/noNonNullAssertion: 없으면 터져야지
6 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
7 | // biome-ignore lint/style/noNonNullAssertion: 없으면 터져야지
8 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
9 | );
10 |
11 | export default supabase;
12 |
--------------------------------------------------------------------------------
/db/meme/create.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 | import { uploadFileToDB } from '@/db/storage';
3 | import { toast } from 'react-toastify';
4 | import { v4 } from 'uuid';
5 |
6 | export async function uploadMemeToDB(title: string, file: Blob) {
7 | const fileExt = file.type.split('/')[1];
8 | const fileName = `${v4()}.${fileExt}`;
9 | const url = await uploadFileToDB(fileName, file);
10 |
11 | toast.success('1/2 이미지 업로드 완료');
12 |
13 | // 이미지 크기 얻기
14 | const { width, height } = await new Promise<{
15 | width: number;
16 | height: number;
17 | }>((resolve) => {
18 | const img = new Image();
19 | img.onload = () => {
20 | const { naturalWidth, naturalHeight } = img;
21 | resolve({ width: naturalWidth, height: naturalHeight });
22 | };
23 | img.src = url;
24 | });
25 |
26 | await supabase
27 | .from('memes')
28 | .insert([{ title, media_url: url, width, height }])
29 | .select()
30 | .single()
31 | .throwOnError();
32 |
33 | toast.success('2/2 밈 추가 완료');
34 | }
35 |
--------------------------------------------------------------------------------
/db/meme/delete.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 | import { getMemeFromDB } from '@/db/meme/read';
3 | import { tryDeleteTagAtDB } from '@/db/memeTag/delete';
4 | import { getMemeTagIdsAtDB } from '@/db/memeTag/read';
5 | import { toast } from 'react-toastify';
6 |
7 | export async function deleteMemeFromDB(id: string) {
8 | const { media_url } = await getMemeFromDB(id);
9 | const tagIds = await getMemeTagIdsAtDB(id);
10 |
11 | // 밈 삭제
12 | await supabase.from('memes').delete().eq('id', id).throwOnError();
13 | toast.success('1/3 밈 삭제 완료');
14 |
15 | // 태그 삭제
16 | await supabase.from('meme_tags').delete().eq('meme_id', id).throwOnError();
17 | // 태그 수 많아봐도 한자리니 Promise.all로 한번에 처리
18 | await Promise.all(tagIds.map(tryDeleteTagAtDB));
19 | toast.success('2/3 태그 삭제 완료');
20 |
21 | // 이미지 삭제
22 | const fileUrl = new URL(media_url);
23 | const filePath = fileUrl.pathname.split(
24 | '/storage/v1/object/public/memes/',
25 | )[1];
26 |
27 | if (!filePath) throw new Error('filePath is null');
28 |
29 | const { error: storageError } = await supabase.storage
30 | .from('memes')
31 | .remove([filePath]);
32 |
33 | if (storageError) throw storageError;
34 |
35 | toast.success('3/3 이미지 삭제 완료');
36 | }
37 |
--------------------------------------------------------------------------------
/db/meme/read.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 |
3 | export async function getMemeFromDB(
4 | id: string,
5 | options?: { includeTags?: boolean },
6 | ) {
7 | const { includeTags = false } = options ?? {};
8 |
9 | if (includeTags) {
10 | const { data } = await supabase
11 | .from('memes')
12 | .select('*, meme_tags(tag_id, tags(id, name))')
13 | .eq('id', id)
14 | .single()
15 | .throwOnError();
16 |
17 | return data;
18 | }
19 |
20 | const { data } = await supabase
21 | .from('memes')
22 | .select('*')
23 | .eq('id', id)
24 | .single()
25 | .throwOnError();
26 |
27 | return data;
28 | }
29 |
30 | export async function getMemesFromDB(tagId?: string) {
31 | if (tagId) {
32 | const { data } = await supabase
33 | .from('meme_tags')
34 | .select(
35 | `
36 | meme_id,
37 | memes(*)
38 | `,
39 | )
40 | .eq('tag_id', tagId)
41 | .throwOnError();
42 |
43 | return data.map(({ memes }) => memes).filter((meme) => meme !== null);
44 | }
45 |
46 | const { data } = await supabase
47 | .from('memes')
48 | .select('*')
49 | .eq('hidden', false)
50 | .order('created_at', { ascending: false })
51 | .limit(10)
52 | .throwOnError();
53 |
54 | return data;
55 | }
56 |
57 | export async function getRandomMemesFromDB(count: number) {
58 | const { data } = await supabase.rpc('get_random_memes', {
59 | p_count: count,
60 | });
61 |
62 | return data;
63 | }
64 |
--------------------------------------------------------------------------------
/db/meme/update.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 | import { connectMemeToTagInDB } from '@/db/memeTag/create';
3 | import { tryDeleteTagAtDB } from '@/db/memeTag/delete';
4 | import { getMemeTagIdsAtDB } from '@/db/memeTag/read';
5 | import type { Meme } from '@/types/helper.types';
6 |
7 | export type UpdateMemeAtDBProps = Partial &
8 | Pick & { tags?: string[] };
9 |
10 | export async function updateMemeAtDB({
11 | id,
12 | tags,
13 | ...rest
14 | }: UpdateMemeAtDBProps) {
15 | await supabase.from('memes').update(rest).eq('id', id).throwOnError();
16 |
17 | if (!tags) return;
18 |
19 | // 기존 태그 삭제
20 | const oldTagIds = await getMemeTagIdsAtDB(id);
21 | await supabase.from('meme_tags').delete().eq('meme_id', id).throwOnError();
22 | await Promise.all(oldTagIds.map(tryDeleteTagAtDB));
23 |
24 | // 새 태그 연결
25 | await Promise.all(tags.map((tagName) => connectMemeToTagInDB(id, tagName)));
26 | }
27 |
--------------------------------------------------------------------------------
/db/memeTag/create.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 |
3 | export const connectMemeToTagInDB = async (memeId: string, tagName: string) => {
4 | // 기존 태그 찾기
5 | const { data: existingTag } = await supabase
6 | .from('tags')
7 | .select('id, name')
8 | .eq('name', tagName)
9 | .maybeSingle()
10 | .throwOnError();
11 |
12 | let tagId: string;
13 |
14 | // 태그가 없으면 새로 생성
15 | if (!existingTag) {
16 | const { data: newTag } = await supabase
17 | .from('tags')
18 | .insert([{ name: tagName }])
19 | .select()
20 | .single()
21 | .throwOnError();
22 | tagId = newTag.id;
23 | } else {
24 | tagId = existingTag.id;
25 | }
26 |
27 | // 밈과 태그 연결
28 | await supabase
29 | .from('meme_tags')
30 | .insert([{ meme_id: memeId, tag_id: tagId }])
31 | .throwOnError();
32 | };
33 |
--------------------------------------------------------------------------------
/db/memeTag/delete.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 |
3 | export const tryDeleteTagAtDB = async (tagId: string) => {
4 | const { count } = await supabase
5 | .from('meme_tags')
6 | .select('*', { count: 'exact', head: true })
7 | .eq('tag_id', tagId)
8 | .throwOnError();
9 |
10 | if (count === null) {
11 | throw new Error('count is null');
12 | }
13 |
14 | if (count === 0) {
15 | await supabase.from('tags').delete().eq('id', tagId).throwOnError();
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/db/memeTag/read.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 |
3 | export async function getTagsAtDB() {
4 | const { data } = await supabase
5 | .from('tags')
6 | .select()
7 | .order('name', { ascending: true })
8 | .throwOnError();
9 |
10 | // TODO: colocation 어쩌구를 안해서 클라에서 정렬
11 | return data.sort((a, b) => a.name.localeCompare(b.name));
12 | }
13 |
14 | export async function getMemeTagIdsAtDB(memeId: string) {
15 | // 왜 string | null ??
16 | const { data } = await supabase
17 | .from('meme_tags')
18 | .select('tag_id')
19 | .eq('meme_id', memeId)
20 | .throwOnError();
21 |
22 | return (
23 | data
24 | .map((tag) => tag.tag_id)
25 | .filter((id): id is string => id !== null)
26 | // TODO: colocation 어쩌구를 안해서 클라에서 정렬
27 | .sort((a, b) => a.localeCompare(b))
28 | );
29 | }
30 |
31 | export async function getMemeTagsAtDB(memeId: string) {
32 | const { data } = await supabase
33 | .from('meme_tags')
34 | .select('tag_id')
35 | .eq('meme_id', memeId)
36 | .throwOnError();
37 |
38 | return data;
39 | }
40 |
--------------------------------------------------------------------------------
/db/storage.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 |
3 | export async function uploadFileToDB(
4 | filePath: string,
5 | file: File | Blob,
6 | ): Promise {
7 | // TODO: 여기서 data로 바로 반환 못하나?
8 | const { error: uploadError } = await supabase.storage
9 | .from('memes')
10 | .upload(filePath, file);
11 |
12 | if (uploadError) throw uploadError;
13 |
14 | const { data: publicUrlData } = supabase.storage
15 | .from('memes')
16 | .getPublicUrl(filePath);
17 |
18 | return publicUrlData.publicUrl;
19 | }
20 |
--------------------------------------------------------------------------------
/i18n/navigation.ts:
--------------------------------------------------------------------------------
1 | import { createNavigation } from 'next-intl/navigation';
2 | import { routing } from './routing';
3 |
4 | // Lightweight wrappers around Next.js' navigation
5 | // APIs that consider the routing configuration
6 | export const { Link, redirect, usePathname, useRouter, getPathname } =
7 | createNavigation(routing);
8 |
--------------------------------------------------------------------------------
/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import { hasLocale } from 'next-intl';
2 | import { getRequestConfig } from 'next-intl/server';
3 | import { routing } from './routing';
4 |
5 | export default getRequestConfig(async ({ requestLocale }) => {
6 | // Typically corresponds to the `[locale]` segment
7 | const requested = await requestLocale;
8 | const locale = hasLocale(routing.locales, requested)
9 | ? requested
10 | : routing.defaultLocale;
11 |
12 | return {
13 | locale,
14 | messages: (await import(`../messages/${locale}.json`)).default,
15 | };
16 | });
17 |
--------------------------------------------------------------------------------
/i18n/routing.ts:
--------------------------------------------------------------------------------
1 | import { defineRouting } from 'next-intl/routing';
2 |
3 | export const routing = defineRouting({
4 | // A list of all locales that are supported
5 | locales: ['en', 'ko'],
6 |
7 | // Used when no locale matches
8 | defaultLocale: 'ko',
9 | localePrefix: 'as-needed',
10 | });
11 |
--------------------------------------------------------------------------------
/i18n/type.ts:
--------------------------------------------------------------------------------
1 | import type { routing } from '@/i18n/routing';
2 | import type messages from '@/messages/en.json';
3 |
4 | declare module 'next-intl' {
5 | interface AppConfig {
6 | Locale: (typeof routing.locales)[number];
7 | Messages: typeof messages;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/knip.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/knip@5/schema.json",
3 | "entry": [
4 | "mdx/**/*.mdx",
5 | "script/**",
6 | "types/database.types.ts",
7 | "i18n/navigation.ts"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/lint-staged.config.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | '*': [
3 | 'biome check --write --no-errors-on-unmatched --files-ignore-unknown=true',
4 | ],
5 | '*.mdx': ['prettier --write'],
6 | '**/*.ts?(x)': () => 'tsc -p tsconfig.json --noEmit',
7 | };
8 |
--------------------------------------------------------------------------------
/mdx-components.tsx:
--------------------------------------------------------------------------------
1 | import Image from '@/components/mdx/Image';
2 | import Pre from '@/components/mdx/Pre';
3 | import type { MDXComponents } from 'mdx/types';
4 |
5 | export function useMDXComponents(components: MDXComponents): MDXComponents {
6 | return {
7 | ...components,
8 | a(props) {
9 | // 현재 창에서 열리면 뒤로가기로 되돌아갔을 때 상태(details 열림 상태 등)가 초기화됨.
10 | if (props.href?.startsWith('https://')) {
11 | return ;
12 | }
13 | // 그렇다고 내 블로그를 새 창으로 여는건 비직관적이므로...
14 | return ;
15 | },
16 | pre: Pre,
17 | Image,
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/mdx/blog-cursor/assets/action.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/blog-cursor/assets/action.png
--------------------------------------------------------------------------------
/mdx/blog-cursor/assets/cursor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/blog-cursor/assets/cursor.png
--------------------------------------------------------------------------------
/mdx/blog-cursor/assets/error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/blog-cursor/assets/error.png
--------------------------------------------------------------------------------
/mdx/blog-cursor/assets/vibe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/blog-cursor/assets/vibe.png
--------------------------------------------------------------------------------
/mdx/blog-cursor/ko.mdx:
--------------------------------------------------------------------------------
1 | export const title = 'Cursor와 함께 풀스택 블로그 만들기';
2 | export const date = '2025-04-13';
3 |
4 | import cursor from './assets/cursor.png';
5 | import error from './assets/error.png';
6 | import Comments from '@/components/comment';
7 | import vibe from './assets/vibe.png';
8 | import action from './assets/action.png';
9 |
10 | 저는 백엔드가 아직 어색한 프론트엔드 개발자입니다. 예전 블로그를 만들 때도 댓글
11 | 기능은 [giscus](https://giscus.app/ko)로 처리하는 등 백엔드 지식이 필요한 부분은
12 | 대부분 간접적으로 해결했어요.
13 |
14 | 하지만 컨텐츠를 자유롭게 보여줄 수 있는 저만의 공간이 필요하다고 느꼈는데 이를
15 | 위해서는 블로그 백엔드가 필요했어요.
16 | [인스타 개발 계정](https://instagram.com/yeol.dev)을 운영하면서 개발 컨텐츠를
17 | 종종 올리는데 이미지/영상 중심의 플랫폼이다 보니 제약이 많았기 때문이에요.
18 |
19 | 이렇게 백엔드가 있는 블로그에 대한 니즈가 생기던 중 최근 등장한 Cursor 같은 AI
20 | 에디터 덕분에 풀스택 블로그의 가능성을 보게 됐어요. 이제 이것저것 물어볼 백엔드
21 | 개발자가 에디터 안에 있으니까요.
22 |
23 |
24 |
25 | 그래서 이번에 도전해봤습니다. 두 번의 주말을 바쳐 블로그를 완성했고, 그 과정을
26 | 공유해보려고 해요. 우선 직접 만든 댓글 컴포넌트 보고 가세요🙌 ~~제 계정으로만
27 | 테스트해봐서 버그가 있을지도~~
28 |
29 |
30 |
31 | ## 커서로 아는 거 구현하기
32 |
33 | 제게 익숙한 프론트엔드 코드를 짤 때와, 익숙하지 않은 백엔드 코드를 짤 때 커서를
34 | 활용하는 방식이 꽤 달랐다는 점이 인상 깊었어요.
35 |
36 | 프론트엔드에서는 주로 귀찮은 부분을 커서에게 시켰습니다. 예를 들어 댓글 뷰어처럼
37 | 복잡하지 않은 컴포넌트는 꽤 잘 만들어주더라고요. **이미 방법을 알고 있는 작업을
38 | 커서가 대신 해준다는게 가장 큰 장점이었어요.** 덕분에 저는 디테일에 집중할 수
39 | 있었죠.
40 |
41 | 예를 들어 네트워크 에러나 로그인 안 된 상태처럼 자주 나오는 에러 처리도 커서가
42 | 적당히 구현해줘서 저는 에러 처리를 위한 코드(try-catch, if...)보다 에러 처리를
43 | 위한 핸들러를 어떻게 구성할지, 유저에게 어떤 메시지를 보여줄지 더 고민할 수
44 | 있었습니다.
45 |
46 |
47 |
48 | 모달같은 기본적인 컴포넌트도 접근성이나 focus처리등을 고려하면 제대로 만드는게
49 | 어렵다는거 알고 계신가요? 그래서 [radix-ui](https://www.radix-ui.com/)를 써보고
50 | 싶었는데 **공부하기는 귀찮아서 커서에게 부탁했습니다!** ~~자랑이다~~. 댓글 삭제
51 | 확인 모달을 잘 만들어주더라고요. 실무에서 이러면 안되겠지만 사이드 프로젝트에서
52 | 써보고 싶은 기술이 있는데 문서 읽어볼 엄두가 안난다면 이런 식으로 첫걸음을
53 | 내딛어도 좋은 것 같습니다.
54 |
55 | 이외에 주의할 점은 '댓글 기능 구현해줘'처럼 추상적이고 큰 단위로 요구하면 커서가
56 | 종종 의도하지 않은 코드를 와장창 쏟아낼 때가 많았습니다. 그래서 **컴포넌트
57 | 단위로 요구사항을 잘게 나눠서 주는 방식**으로 작업해야 했어요. 이렇게 단위가
58 | 작을수록 코드 리뷰하기도 훨씬 편했습니다. 헛소리하는 토큰을 아낄 수도 있고요.
59 |
60 | 예전에는 혼자 코드를 짠다는 느낌이었다면, 커서를 활용하면서는 혼자하는
61 | 사이드프로젝트인데 PR 리뷰하는 느낌이 들었습니다.
62 |
63 | ## 커서로 모르는 거 구현하기
64 |
65 | 백엔드 작업은 제게 낯선 부분이 많았기 때문에 커서에게 기능을 설명하고 그에 맞는
66 | 쿼리나 서버 액션 코드를 짜달라고 요청하는 방식으로 사용했습니다. 예를 들어
67 | "유저별로 댓글을 관리하고싶은데, 이걸 담을 수 있는 테이블을 만들어줘"라고
68 | 설명하면 적절한 테이블 스키마와 쿼리를 생성해줬습니다.
69 |
70 | 종종 테이블을 만들 때 제 블로그에서는 불필요한 필드를 넣는 경우가 있어서 수정
71 | 요청을 하기도 했습니다. 사실 본격적인 서비스라면 넣을 법한 필드들이었는데 저는
72 | 최대한 단순하게 만들고 싶어서 쳐냈었어요.
73 |
74 |
75 |
76 | _(백엔드랑 같이 개발하니 서버 액션이 확실히 편하긴하네요. REST 엔드포인트 다
77 | 만들어야했으면 더 귀찮았을 듯...)_
78 |
79 | 기본키나 외래키같은게 뭔지는 대충 알아서 테이블 설계까지는 이해했지만 DB
80 | function이나 trigger는 어떻게 돌아가는지 아직 잘 모르겠더라고요. 이런게
81 | 쌓이다보면 손도 못대는 이해 불가능한 프로젝트가 되어버리니 **이런 부분들을 잘
82 | 캡슐화하고 관리하고 공부하는게** 중요하겠다 생각했습니다.
83 |
84 | Supabase를 썼기 때문에 RLS(Row Level Security) 같은 보안 설정도 함께 해줘야
85 | 했는데요 이 부분도 커서가 자동으로 짜줘서 굉장히 편리했습니다. Supabase의
86 | 단점으로 'RLS 설정이 번거롭다'는 얘기를 들었는데 저는 커서 덕분에 큰 스트레스
87 | 없이 넘어갈 수 있었습니다.
88 |
89 | ## 느낀점
90 |
91 | 전반적으로 저는 프론트엔드 작업에서는 코드의 디테일을 챙기는 역할을, 백엔드
92 | 작업에서는 요구사항을 커서 제대로 이해했는지 확인하는 역할을 맡았던 것 같아요.
93 |
94 | **좋은 결과물을 만들려면 "무엇이 가능한지를 아는 것"이 정말 중요하다는 걸
95 | 느꼈습니다.** 백엔드는 제가 잘 모르다 보니, 커서가 이상하거나 비효율적인 코드를
96 | 짜줘도 그걸 눈치채지 못할 때가 있었어요.
97 |
98 | 예를 들어 이모지 반응 남기는 기능을 처음에는 서버 액션에서 개수 가져와서(요청
99 | 1번) 개수에 1을 더하는(요청 2번) 방식으로 구현헀는데 db function을 활용하면
100 | db에서 이 작업을 해주니 요청 1번에 되더라고요. 나중에서야 깨닫고 최적화를
101 | 해줬습니다. 아무래도 **커서는 서비스의 요구사항을 저만큼 모르니 일반적으로 잘
102 | 먹히는 코드를 짜야해** 발생하는 문제라고 생각해요.
103 |
104 | **최적화는 요구사항에 대한 이해와 기술적인 이해가 동시에 있어야 가능**하다고도
105 | 느꼈습니다. 이번에는 제가 요구사항을 알고 커서가 기술을 알았던 셈이지만, 다음에
106 | 더 주도적이고 효율적으로 커서를 활용하려면 백엔드 공부가 꼭 필요하겠다
107 | 느꼈습니다.
108 |
109 | 마지막으로 Supabase 설정이나 GitHub Auth처럼 **환경 세팅 관련 트러블슈팅**은
110 | 커서만으로 해결하기 어려웠어요. 개발 환경 전반을 이해하는 AI가 얼른 상용화되면
111 | 좋겠네요.
112 |
--------------------------------------------------------------------------------
/mdx/blog-spec/assets/after.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/blog-spec/assets/after.png
--------------------------------------------------------------------------------
/mdx/blog-spec/assets/before.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/blog-spec/assets/before.png
--------------------------------------------------------------------------------
/mdx/blog-spec/tmp.mdx:
--------------------------------------------------------------------------------
1 | import me from '@/app/[locale]/assets/me.jpg';
2 | import before from './assets/before.png';
3 | import after from './assets/after.png';
4 |
5 | export const title = '한글에서만 보이는 글';
6 | export const date = '2025-04-20';
7 |
8 | ## 기술 스택
9 |
10 | 제가 좋아하는 블로그이자 학습 플랫폼인 joshwcomeau.com
11 | [블로그 개발기](https://www.joshwcomeau.com/blog/how-i-built-my-blog-v2/)를 많이
12 | 참고했습니다.
13 |
14 | ### Next.js
15 |
16 | 블로그에서 많은 일을 벌일 것 같아 지원폭이 넓은 **Next.js**를 선택했습니다.
17 | 백엔드 코드도 짤 계획이라 서버 컴포넌트를 지원하는 App router를 사용했어요.
18 |
19 | ### CSS
20 |
21 | **CSS 도구**로는 개발기에서 추천된
22 | [Pigment CSS](https://mui.com/blog/introducing-pigment-css/)를 써보고 싶었지만
23 | 써보니 Next.js에서
24 | [fast refresh가 안되는 이슈](https://github.com/mui/pigment-css/issues/303)가
25 | 있어 포기했습니다. styled-component와 유사한 구조의 도구를 써보고싶었지만
26 | 아쉽게도 대부분 SSR 지원이 시원찮아 **tailwind**를 선택했습니다.
27 | [tailwindcss-typography](https://github.com/tailwindlabs/tailwindcss-typography)를
28 | 사용하면 마크다운 스타일도 깔끔하게 넣을 수 있다는 장점이 있습니다.
29 |
30 | ### MDX
31 |
32 | 게시글은 **MDX** 파일로 작성합니다. 마크다운과 리액트 컴포넌트를 혼합해서 작성할
33 | 수 있다는 유연함이 장점입니다. 이미지를 제가 원하는 리액트 컴포넌트로 보여줄
34 | 수도 있어요. 클릭해보세요!
35 | [medium-zoom](https://github.com/francoischalifour/medium-zoom) 라이브러리를
36 | 활용했어요.
37 |
38 |
39 |
40 | MDX 내부 코드 하이라이팅은 [Shiki](https://shiki.style/)를 사용했습니다.
41 | 게시물을 정적 렌더할 때 서버단에서 동작하며
42 | [highlightjs](https://highlightjs.org/)와의 차이점은 쓰면서 알아봐야겠습니다.
43 |
44 | 한가지 신기한 기능이 있는데 `// [!code highlight]`같은 주석을 달면 아래처럼
45 | 하이라이트 처리를 해주는 플러그인이 있습니다. 플러그인 종류가 많아 인상깊었네요.
46 | [참고](https://shiki.style/packages/transformers).
47 |
48 | ```js
49 | console.log('highlighted');
50 | // [!code highlight]
51 | console.log('highlighted');
52 | console.log('not highlighted');
53 | ```
54 |
55 | ### Supabase
56 |
57 | ### Biome
58 |
59 | 매번 prettier와 eslint를 설정하는건 귀찮은 일입니다.
60 | [Biome](https://biomejs.dev/) 을 사용하면 코드 포맷팅과 린팅을 한번에 해결할 수
61 | 있어요.
62 |
63 | 잠시 써본 후기로는 속도도 빠르고 생전 처음 보는 린트 에러도 있어서 공부가 많이
64 | 됐습니다.
65 |
66 | ### bun
67 |
68 | ## 메모
69 |
70 | **만들면서 기억할만한 것들을 메모했습니다.**
71 |
72 | ### js, mjs 확장자 이슈
73 |
74 | Shiki를 초기 설정할 때
75 | `[ERR_REQUIRE_ESM]: require() of ES Module shiki when using 14.2.x`라는 에러가
76 | 발생했습니다. https://github.com/vercel/next.js/issues/64434
77 |
78 | `next.config.js`를 `next.config.mjs`로 변경하니 해결되었습니다. ESM, CJS 관련
79 | 이슈는 봐도봐도 어려워요...
80 |
81 | ## Supabase 쿼리 빌더의 지연 실행
82 |
83 | 보통 프로미스는 생성되자마자 실행됩니다.
84 |
85 | ```js
86 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
87 | // 여기서 바로 setTimeout 시작
88 | delay(1000);
89 | ```
90 |
91 | 하지만 Supabase 쿼리 빌더는 일반적인 프로미스와 다르게 작동합니다.Supabase 쿼리
92 | 빌더는 실제 프로미스가 아닌 빌더 패턴을 구현한 객체를 반환합니다.
93 |
94 | ```typescript
95 | let query = supabase.from('memes').select(`...`);
96 | ```
97 |
98 | 여기서 `query`는 실제 프로미스가 아니라 쿼리를 정의하는 객체입니다. 실제 HTTP
99 | 요청은 다음과 같은 방법으로 접근할 때 발생합니다:
100 |
101 | 1. `await query`를 호출할 때
102 | 2. `query.then()`을 호출할 때
103 | 3. `.single()`, `.maybeSingle()` 같은 최종 실행 메서드를 호출할 때
104 |
105 | 이런 지연 실행(lazy execution) 패턴은 쿼리를 조합하고 수정할 수 있는 유연성을
106 | 제공합니다.
107 |
108 | ### Vercel 배포 성능 이슈
109 |
110 | 로컬에서는 빠르게 동작하던 앱이 Vercel에 배포 후 매우 느려졌습니다. 이 경우에는
111 | 코드 외적인 배포 리전에 이슈가 있던 거라서 기억에 남네요.
112 |
113 | > By default, Vercel Functions execute in Washington, D.C., USA (iad1) for all
114 | > new projects to ensure they are located close to most external data sources,
115 | > which are hosted on the East Coast of the USA. You can set a new default
116 | > region through your project's settings on Vercel
117 |
118 | Server action은 워싱턴에서 실행되는데 supabase db는 싱가폴에 있고 그걸 접속하는
119 | 저는 한국에 있어서 생긴 이슈였습니다. Supabase 리전은 중도 수정이 안되니
120 | 여러분들은 꼭 잘 보고 만드세요 ㅠㅠ. 저는 새로 만들어서 마이그레이션하느라
121 | 힘들었네요.
122 |
123 | vercel 한국 / supabase 싱가폴 성능
124 |
125 |
126 |
127 | vercel 한국 / supabase 한국 성능
128 |
129 |
130 |
131 | ### localhost와 127.0.0.1 차이로 인한 이슈
132 |
133 | Supabase auth 설정할 때 localhost와 127.0.0.1가 혼용되어 쿠키가 제대로 남지 않는
134 | 이슈가 있었습니다. 하루 꼬박 걸려 디버깅했네요.
135 |
136 | ### 기타 팁
137 |
138 | - **최적화 전략**: 비싼 연산(DB 접속 등)보다 값싼 연산(로컬 네트워크 접속)을
139 | 먼저 해서 빨리 실패하도록 구성했습니다.
140 | - **마크다운 작성 팁**: 마크다운에서 두 줄 띄는 것이 귀찮을 수 있지만, max width
141 | 포맷팅 고려 시 두 줄 띄는 것이 좋습니다. 글 작성 시와 열람 시의 화면 폭이 다를
142 | 수 있기 때문입니다.
143 | - **이미지 속성 활용**: 이미지의 width 속성은 픽셀 단위의 본질적 이미지 너비를
144 | 나타내며, 이미지의 정확한 비율을 추론하고 로딩 중 레이아웃 이동을 방지하는 데
145 | 사용됩니다.
146 |
147 | ## 남은 할 일
148 |
149 | - i18n 지원
150 | - SEO (sitemap 등)
151 |
--------------------------------------------------------------------------------
/mdx/cs/adder/assets/calculator.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/adder/assets/calculator.png
--------------------------------------------------------------------------------
/mdx/cs/adder/assets/gear.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/adder/assets/gear.png
--------------------------------------------------------------------------------
/mdx/cs/adder/assets/half.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": [
3 | {
4 | "id": "43f3afc9-30b5-496e-8012-f03053c70ffd",
5 | "type": "halfAdder",
6 | "position": { "x": 40, "y": 0 },
7 | "data": {
8 | "atoms": {
9 | "type": "halfAdder",
10 | "inputAtoms": { "in1": { "init": null }, "in2": { "init": null } },
11 | "outputAtoms": { "sum": { "init": null }, "carry": { "init": null } },
12 | "effectAtom": {},
13 | "id": "43f3afc9-30b5-496e-8012-f03053c70ffd"
14 | }
15 | },
16 | "measured": { "width": 128, "height": 128 },
17 | "selected": false,
18 | "dragging": false
19 | },
20 | {
21 | "id": "9d557ed5-599e-47e3-89cb-fb306db35104",
22 | "type": "number",
23 | "position": { "x": -64, "y": 76 },
24 | "data": {
25 | "atoms": {
26 | "type": "boolean",
27 | "inputAtoms": {},
28 | "outputAtoms": {
29 | "out": { "init": false },
30 | "label": { "init": null }
31 | },
32 | "id": "9d557ed5-599e-47e3-89cb-fb306db35104"
33 | }
34 | },
35 | "measured": { "width": 40, "height": 40 },
36 | "selected": false,
37 | "dragging": false
38 | },
39 | {
40 | "id": "8a1cde3c-0fd7-4a7c-8b9e-f04d32bf8c2c",
41 | "type": "number",
42 | "position": { "x": -64, "y": 12 },
43 | "data": {
44 | "atoms": {
45 | "type": "boolean",
46 | "inputAtoms": {},
47 | "outputAtoms": {
48 | "out": { "init": false },
49 | "label": { "init": null }
50 | },
51 | "id": "8a1cde3c-0fd7-4a7c-8b9e-f04d32bf8c2c"
52 | }
53 | },
54 | "measured": { "width": 40, "height": 40 },
55 | "selected": false,
56 | "dragging": false
57 | }
58 | ],
59 | "edges": [
60 | {
61 | "type": "default",
62 | "animated": true,
63 | "selectable": true,
64 | "source": "9d557ed5-599e-47e3-89cb-fb306db35104",
65 | "sourceHandle": "out",
66 | "target": "43f3afc9-30b5-496e-8012-f03053c70ffd",
67 | "targetHandle": "in2",
68 | "id": "xy-edge__9d557ed5-599e-47e3-89cb-fb306db35104out-43f3afc9-30b5-496e-8012-f03053c70ffdin2"
69 | },
70 | {
71 | "type": "default",
72 | "animated": true,
73 | "selectable": true,
74 | "source": "8a1cde3c-0fd7-4a7c-8b9e-f04d32bf8c2c",
75 | "sourceHandle": "out",
76 | "target": "43f3afc9-30b5-496e-8012-f03053c70ffd",
77 | "targetHandle": "in1",
78 | "id": "xy-edge__8a1cde3c-0fd7-4a7c-8b9e-f04d32bf8c2cout-43f3afc9-30b5-496e-8012-f03053c70ffdin1"
79 | }
80 | ],
81 | "viewport": { "x": 109, "y": 86, "zoom": 1 },
82 | "nodeOutputs": {
83 | "8ccf635f-7331-41a7-81c1-58bd89f14484": { "sum": false, "carry": false },
84 | "3d7fb51c-7814-49a1-85c8-4e5816dbdaf9": { "out": false, "label": null },
85 | "45da19ca-d3f0-4013-bcff-6c00abcd8898": { "out": false, "label": null },
86 | "43f3afc9-30b5-496e-8012-f03053c70ffd": { "sum": false, "carry": false },
87 | "9d557ed5-599e-47e3-89cb-fb306db35104": { "out": false, "label": "B" },
88 | "8a1cde3c-0fd7-4a7c-8b9e-f04d32bf8c2c": { "out": false, "label": "A" }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/mdx/cs/and-or-not/assets/circuit.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/and-or-not/assets/circuit.jpg
--------------------------------------------------------------------------------
/mdx/cs/and-or-not/assets/lego.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/and-or-not/assets/lego.jpg
--------------------------------------------------------------------------------
/mdx/cs/and-or-not/assets/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/and-or-not/assets/og.png
--------------------------------------------------------------------------------
/mdx/cs/index.ts:
--------------------------------------------------------------------------------
1 | export const order = [
2 | 'zero-and-one',
3 | 'and-or-not',
4 | 'nand-is-all-you-need',
5 | 'adder',
6 | ];
7 |
--------------------------------------------------------------------------------
/mdx/cs/nand-is-all-you-need/assets/not.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": [
3 | {
4 | "id": "d647c134-6b87-42a3-8316-03e5f9cb0708",
5 | "type": "number",
6 | "position": { "x": -20, "y": 4 },
7 | "data": {
8 | "atoms": {
9 | "type": "boolean",
10 | "inputAtoms": {},
11 | "outputAtoms": {
12 | "out": { "init": false },
13 | "label": { "init": null }
14 | },
15 | "id": "d647c134-6b87-42a3-8316-03e5f9cb0708"
16 | }
17 | },
18 | "measured": { "width": 40, "height": 40 },
19 | "selected": true,
20 | "dragging": false
21 | },
22 | {
23 | "id": "9e753044-6c92-45d2-8c17-900442a8eca2",
24 | "type": "nand",
25 | "position": { "x": 84, "y": -8 },
26 | "data": {
27 | "atoms": {
28 | "type": "nand",
29 | "inputAtoms": { "in1": { "init": null }, "in2": { "init": null } },
30 | "outputAtoms": { "out": { "init": null } },
31 | "effectAtom": {},
32 | "id": "9e753044-6c92-45d2-8c17-900442a8eca2"
33 | }
34 | },
35 | "measured": { "width": 96, "height": 64 },
36 | "selected": false,
37 | "dragging": false
38 | },
39 | {
40 | "id": "fb1b6506-1cfd-49bd-9413-01424a8683c2",
41 | "type": "label",
42 | "position": { "x": 252, "y": 4 },
43 | "data": {
44 | "atoms": {
45 | "type": "label",
46 | "inputAtoms": { "in": { "init": null } },
47 | "outputAtoms": { "label": { "init": null }, "out": { "init": null } },
48 | "effectAtom": {},
49 | "id": "fb1b6506-1cfd-49bd-9413-01424a8683c2"
50 | }
51 | },
52 | "measured": { "width": 40, "height": 40 },
53 | "selected": false,
54 | "dragging": false
55 | }
56 | ],
57 | "edges": [
58 | {
59 | "type": "default",
60 | "animated": true,
61 | "selectable": true,
62 | "source": "d647c134-6b87-42a3-8316-03e5f9cb0708",
63 | "sourceHandle": "out",
64 | "target": "9e753044-6c92-45d2-8c17-900442a8eca2",
65 | "targetHandle": "in1",
66 | "id": "xy-edge__d647c134-6b87-42a3-8316-03e5f9cb0708out-9e753044-6c92-45d2-8c17-900442a8eca2in1"
67 | },
68 | {
69 | "type": "default",
70 | "animated": true,
71 | "selectable": true,
72 | "source": "d647c134-6b87-42a3-8316-03e5f9cb0708",
73 | "sourceHandle": "out",
74 | "target": "9e753044-6c92-45d2-8c17-900442a8eca2",
75 | "targetHandle": "in2",
76 | "id": "xy-edge__d647c134-6b87-42a3-8316-03e5f9cb0708out-9e753044-6c92-45d2-8c17-900442a8eca2in2"
77 | },
78 | {
79 | "type": "default",
80 | "animated": true,
81 | "selectable": true,
82 | "source": "9e753044-6c92-45d2-8c17-900442a8eca2",
83 | "sourceHandle": "out",
84 | "target": "fb1b6506-1cfd-49bd-9413-01424a8683c2",
85 | "targetHandle": "in",
86 | "id": "xy-edge__9e753044-6c92-45d2-8c17-900442a8eca2out-fb1b6506-1cfd-49bd-9413-01424a8683c2in"
87 | }
88 | ],
89 | "viewport": { "x": 56.33333333333334, "y": 103, "zoom": 0.9166666666666666 },
90 | "nodeOutputs": {
91 | "3baeb21b-87c8-4203-a3e1-063b6d8873ea": { "out": false, "label": "x" },
92 | "d73b5b17-9185-4b9b-bdd9-05b10312b184": { "out": true },
93 | "35d73678-1ea0-4402-bfcc-1d12674d19e5": { "label": "NOT x", "out": true },
94 | "d647c134-6b87-42a3-8316-03e5f9cb0708": { "out": false, "label": "x" },
95 | "9e753044-6c92-45d2-8c17-900442a8eca2": { "out": true },
96 | "fb1b6506-1cfd-49bd-9413-01424a8683c2": { "label": "NOT x", "out": true }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/mdx/cs/nand-is-all-you-need/en.mdx:
--------------------------------------------------------------------------------
1 | export const title = 'The computer that didn’t quite like both mom and dad';
2 | export const description = 'Let’s see how we can create everything in computing from just one simple function';
3 |
4 | import TruthTable from '@/components/cs/TruthTable';
5 | import Flow from '@/components/cs/flow';
6 |
7 | import not from './assets/not.json';
8 | import and from './assets/and.json';
9 | import or from './assets/or.json';
10 |
11 | In the previous post, we explored three basic logic gates: AND, OR, and NOT. We also discovered that by combining these gates, we can construct any logical circuit expressible through truth tables—much like stacking Lego blocks to build complex structures.
12 |
13 | However, from the perspective of computer hardware engineers, a different consideration emerges. Since computers aren't human, rather than intuitive or easy-to-understand designs, the crucial factor is **physical efficiency and simplicity**. If every possible logical component could be built using **just one type of component**, the manufacturing process would become significantly simpler and more cost-effective.
14 |
15 | Remarkably, it turns out any complex logic circuit in the world can be constructed exclusively using a single type of gate: the **NAND gate**. This property is known as the **Completeness or Universality of NAND**. Today, we will explore the NAND gate and practically build other fundamental gates using only NAND.
16 |
17 | ## NAND Gate
18 |
19 | A NAND gate, as its name suggests, is the **inverse of an AND operation**. It returns false only when both inputs are true, and true in every other case. If A means 'mom is liked' and B means 'dad is liked,' then A NAND B would mean 'we don’t quite like both mom and dad.'
20 |
21 | Here's the truth table for the NAND gate:
22 |
23 |
36 |
37 | ## Creating AND, OR, NOT from NAND
38 |
39 | Let's now create the NOT, OR, and AND gates we discussed previously using only NAND gates.
40 |
41 | ### Creating NOT
42 |
43 | Let's start with the simple NOT gate. What happens if we connect the same value to both inputs of a NAND gate?
44 |
45 |
46 |
47 | Click on the rectangular node to change the input value. Notice how the output is always the inverse of the input?
48 |
49 | ### Making AND Yourself!
50 |
51 | A NAND gate outputs the inverse of an AND operation. So what if we invert the output of a NAND gate again?
52 |
53 | Try creating it yourself using the playground below. Click on the buttons to the left to add gates, then connect the dots to wire them together.
54 |
55 |
56 |
57 |
58 |
59 |
60 | View Example Solution
61 |
62 | Connecting a NAND gate's output back into another NAND gate’s inputs creates an AND gate.
63 |
64 |
65 | ### Creating OR
66 |
67 | Creating an OR gate requires a bit more thought, and here we use De Morgan’s laws. The OR gate can be expressed using NAND as follows:
68 |
69 | 1. `A OR B`
70 | 1. `NOT ((NOT A) AND (NOT B))`
71 | 1. `(NOT A) NAND (NOT B)`
72 |
73 | Usually, De Morgan’s laws are applied from step 2 to 1, but here we use them in reverse, which can be tricky. Here's how it looks implemented:
74 |
75 |
76 |
77 | ## The Universality of NAND Gates
78 |
79 | We've now confirmed that using only NAND gates, we can create NOT, OR, and AND gates. And from the previous post, we know that any logical circuit can be built from AND, OR, and NOT gates.
80 |
81 | Thus, we reach the conclusion that **NAND gates alone can theoretically build every digital logic circuit in existence**. This is known as the **Universality of NAND gates**.
82 |
83 | Why is this important? From a mass-production standpoint in computer hardware, reducing component diversity significantly simplifies and streamlines the manufacturing process. Producing just one basic component dramatically reduces complexity and manufacturing costs.
84 |
85 | ## Wrapping Up
86 |
87 | Isn’t it astonishing that complex computer hardware essentially boils down to combinations of simple NAND gates?
88 |
89 | Now, we better understand how computers implement logical meanings physically with bits (0 and 1). We also know that these basic gates can be combined to build any logical circuit imaginable.
90 |
91 | In the next post, we'll explore how to combine these gates to construct circuits capable of performing **actual calculations**. At last, we’ll enter the realm of arithmetic, where computers can perform operations like addition and subtraction!
92 |
--------------------------------------------------------------------------------
/mdx/cs/nand-is-all-you-need/ko.mdx:
--------------------------------------------------------------------------------
1 | export const title = '사실 엄마 아빠가 둘 다 좋지는 않았던 컴퓨터'
2 | export const description = '간단한 함수 하나로 컴퓨터의 모든걸 만들 수 있음을 확인해봅시다'
3 |
4 | import TruthTable from '@/components/cs/TruthTable';
5 | import Flow from '@/components/cs/flow';
6 |
7 | import not from './assets/not.json';
8 | import and from './assets/and.json';
9 | import or from './assets/or.json';
10 |
11 | 지난 게시물에서는 AND, OR, NOT이라는 세 가지 기본 논리 게이트를 살펴보고 이 게이트들을 조합하면 진리표로 표현 가능한 모든 논리 회로를 만들 수 있다는 것을 확인했습니다. 마치 레고 블록처럼 이 기본 게이트들을 쌓아 복잡한 기능을 구현하는 것이죠.
12 |
13 | 그런데 컴퓨터 하드웨어를 만드는 엔지니어의 입장에서는 조금 다른 생각이 들 수 있습니다. 컴퓨터는 사람이 아니기에 직관성이나 이해하기 쉬움보다는 **물리적으로 구현하기 얼마나 효율적이고 간단한지**가 훨씬 중요합니다. 다양한 종류의 기본 부품을 사용하는 것보다, **단 한 가지 종류의 부품만으로 모든 것을 만들 수 있다면** 제조 과정이 훨씬 단순해지고 비용도 절감될 것입니다.
14 |
15 | 놀랍게도, 세상의 그 어떤 복잡한 논리 회로도 오직 **NAND 게이트** 하나만으로 모두 만들 수 있습니다. 이를 **NAND의 완전성(Completeness) 또는 보편성(Universality)** 이라고 부릅니다. 이번 게시물에서는 이 NAND 게이트에 대해 알아보고 실제로 다른 기본 게이트들을 만들어보겠습니다.
16 |
17 | ## NAND 게이트
18 |
19 | NAND 게이트는 이름에서 알 수 있듯이 **AND 연산의 결과에 NOT 연산을 적용한 것**입니다. 즉, 두 입력이 모두 참일 때만 거짓이 되고, 그 외의 경우에는 모두 참이 됩니다. A가 '엄마가 좋다'고 B가 '아빠가 좋다'면 A NAND B는 '엄마 아빠가 둘 다 좋지는 않다'인 셈이죠.
20 |
21 | NAND 게이트의 진리표는 다음과 같습니다.
22 |
23 |
36 |
37 | ## NAND로 AND OR NOT 만들기
38 |
39 | NAND 게이트로 지난번에 살펴본 NOT, OR, AND 게이트를 직접 구현해봅시다.
40 |
41 | ### NOT 만들기
42 |
43 | 우선 비교적 단순한 NOT 게이트부터 만들어봅시다. NAND 게이트의 두 입력에 같은 값을 연결해볼까요?
44 |
45 |
46 |
47 | 사각형 노드를 클릭해 입력값을 바꿔보세요. 출력이 입력의 반대가 되는게 잘 보이시나요?
48 |
49 | ### 직접 AND 만들어보기!
50 |
51 | NAND 게이트는 AND 결과의 정반대 출력을 내놓죠. 그렇다면 NAND 게이트의 출력 값을 **반전**시키면 어떻게 될까요?
52 |
53 | 아래에 준비된 플레이그라운드를 활용해 직접 만들어보세요. 좌측 버튼들을 클릭해 게이트를 추가하고, 점끼리 이어 게이트들끼리 연결시켜보세요.
54 |
55 |
56 |
57 |
58 |
59 |
60 | 예시 답안 보기
61 |
62 | NAND 게이트의 출력을 NAND 게이트의 입력들로 연결하면 AND 게이트가 됩니다.
63 |
64 |
65 |
66 | ### OR 만들기
67 |
68 | OR 게이트는 조금 고민이 필요한데요, 드모르간 법칙을 활용해볼 수 있습니다. 아래 순서대로 OR을 NAND로 표현할 수 있어요:
69 |
70 | 1. `A OR B`
71 | 1. `NOT ((NOT A) AND (NOT B))`
72 | 1. `(NOT A) NAND (NOT B)`
73 |
74 | 보통 2->1 방향으로 드모르간 법칙을 사용하는데 여기선 역순으로 사용해서 생각해내기 어렵죠. 위 방법대로 구현한 모습은 아래와 같습니다:
75 |
76 |
77 |
78 | ## NAND 게이트의 완전성 (Universality)
79 |
80 | 이렇게 NAND 게이트 하나만으로 NOT, OR, AND 게이트를 모두 구현할 수 있음을 확인했습니다. 그리고 지난 게시물에서는 AND, OR, NOT만 있으면 어떤 논리 회로든 만들 수 있다는 것을 배웠죠.
81 |
82 | 따라서 **NAND 게이트 하나만으로도 이론적으로 세상의 모든 디지털 논리 회로를 구성하는 것이 가능**하다는 결론에 도달합니다. 이것이 바로 **NAND 게이트의 완전성(Universality)** 입니다.
83 |
84 | 왜 이것이 중요할까요? 컴퓨터 하드웨어를 대량으로 생산하는 입장에서는 부품의 종류가 적을수록 생산 과정이 훨씬 효율적이고 단순해지기 때문입니다. 단 한 종류의 기본 부품만 찍어내서 연결하면 되니, 제조 비용을 크게 절감하고 복잡성을 줄일 수 있습니다.
85 |
86 | ## 마무리
87 |
88 | 복잡한 컴퓨터 하드웨어가 결국에는 이처럼 아주 단순한 NAND의 조합으로 이루어질 수 있다는 사실이 정말 놀랍지 않나요?
89 |
90 | 이제 컴퓨터가 사용하는 비트(0과 1)가 어떻게 논리적인 의미를 가지고 물리적인 부품으로 구현되는지 이해하게 되었습니다. 이 기본 논리 게이트들을 조합하면 어떤 논리 회로든 만들 수 있다는 것도 알게 되었죠.
91 |
92 | 다음 게시물에서는 이 논리 게이트들을 조합하여 **실제로 간단한 계산을 수행하는 회로**를 어떻게 만드는지 살펴보겠습니다. 드디어 컴퓨터가 숫자를 가지고 덧셈, 뺄셈 같은 연산을 하는 영역으로 들어가네요!
93 |
--------------------------------------------------------------------------------
/mdx/cs/zero-and-one/assets/cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/zero-and-one/assets/cover.png
--------------------------------------------------------------------------------
/mdx/cs/zero-and-one/assets/lp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/zero-and-one/assets/lp.png
--------------------------------------------------------------------------------
/mdx/cs/zero-and-one/assets/painting.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/zero-and-one/assets/painting.png
--------------------------------------------------------------------------------
/mdx/cs/zero-and-one/assets/shannon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/zero-and-one/assets/shannon.png
--------------------------------------------------------------------------------
/mdx/cs/zero-and-one/assets/zeroone.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/cs/zero-and-one/assets/zeroone.png
--------------------------------------------------------------------------------
/mdx/react/assets/dom-stack.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/react/assets/dom-stack.png
--------------------------------------------------------------------------------
/mdx/react/assets/effect-stack.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/react/assets/effect-stack.png
--------------------------------------------------------------------------------
/mdx/react/assets/render-stack.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/mdx/react/assets/render-stack.png
--------------------------------------------------------------------------------
/mdx/thenable/ko.mdx:
--------------------------------------------------------------------------------
1 | export const title = 'PromiseLike로 비동기 요청 미루기';
2 | export const date = '2025-04-20';
3 |
4 | Supabase로 db를 조회하던 코드를 짜던 중 비동기 요청은 어느 시점에 되는지에 대한
5 | 궁금증이 생겼습니다. 평소에 프로미스 객체를 쓸 때와는 다른 경험이었거든요.
6 |
7 | ## 궁금증
8 |
9 | 프로미스 객체가 생기는 시점과 비동기 요청이 이루어지는 시점은 일반적으로
10 | 동일합니다. `fetch`나 `setTimeout`를 사용할 때를 생각해보세요. 예를 들어
11 | `setTimeout`을 사용해 만든 아래 `delay` 함수는 프로미스 객체가 생성되는 즉시
12 | 비동기 작업이 시작됩니다.
13 |
14 | ```js
15 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16 | delay(1000); // await 유무와 무관하게 여기서 바로 1초를 세기 시작
17 | ```
18 |
19 | 하지만 supabase를 사용할 때는 await 이전까지는 비동기 요청을 하지 않습니다. 이
20 | 특성 덕분에 조건부로 쿼리를 추가한다음 쿼리가 완성되면 await로 비동기 요청을
21 | 시작할 수도 있습니다.
22 |
23 | ```ts
24 | let query = supabase.from('...').select('...');
25 | if (tagIds) query = query.in('id', tagIds);
26 |
27 | // [!code highlight]
28 | const { data, error } = await query;
29 | ```
30 |
31 | 하나 더 특이한 점은 await를 여러번 한다고 해도 그때그때의 쿼리에 맞춰 응답이 잘
32 | 온다는 점입니다.
33 |
34 | ```ts
35 | let query = supabase.from('...').select('...');
36 | // [!code highlight]
37 | let { data, error } = await query; // ✅
38 |
39 | query = query.order('created_at', { ascending: true });
40 | // [!code highlight]
41 | { data, error } = await query; // ✅
42 | ```
43 |
44 | `select`나 `order` 메서드들의 타입을 보면 `PostgrestTransformBuilder`,
45 | `PostgrestFilterBuilder`같이 프로미스가 아닌 커스텀 객체를 반환합니다. 쿼리를
46 | 단계별로 만들기위해
47 | [빌더 패턴](https://refactoring.guru/ko/design-patterns/builder)를 활용한건
48 | 알겠는데 어떻게 프로미스가 아닌 객체에 await을 할 수 있는걸까요?
49 |
50 | ## 리서치
51 |
52 | [supabase 소스코드](https://github.com/supabase/postgrest-js/blob/master/src/PostgrestBuilder.ts#L15)를
53 | 확인해보면 `PostgrestTransformBuilder`나 `PostgrestFilterBuilder` 모두
54 | `PostgrestBuilder`를 상속(extends)받은 클래스이며 `PostgrestBuidler`는
55 | `PromiseLike`를 구현(implements)하고 있습니다.
56 |
57 | ```ts
58 | export default abstract class PostgrestBuilder<
59 | Result,
60 | ThrowOnError extends boolean = false,
61 | > implements
62 | PromiseLike<
63 | ThrowOnError extends true
64 | ? PostgrestResponseSuccess
65 | : PostgrestSingleResponse
66 | > {
67 | // ...
68 | }
69 | ```
70 |
71 | [TypeScript 소스코드](https://github.com/microsoft/TypeScript/blob/main/src/lib/es5.d.ts#L1512)를
72 | 보면 `PromiseLike`란 `then` 메서드를 구현한 객체를 의미합니다.
73 |
74 | ```ts
75 | interface PromiseLike {
76 | /**
77 | * Attaches callbacks for the resolution and/or rejection of the Promise.
78 | * @param onfulfilled The callback to execute when the Promise is resolved.
79 | * @param onrejected The callback to execute when the Promise is rejected.
80 | * @returns A Promise for the completion of which ever callback is executed.
81 | */
82 | then(
83 | onfulfilled?:
84 | | ((value: T) => TResult1 | PromiseLike)
85 | | undefined
86 | | null,
87 | onrejected?:
88 | | ((reason: any) => TResult2 | PromiseLike)
89 | | undefined
90 | | null,
91 | ): PromiseLike;
92 | }
93 | ```
94 |
95 | 그렇다면 `PostgrestBuilder`는 `then` 메서드를 구현했을테니 살펴봅시다.
96 |
97 | ```ts
98 | // 일부 코드 생략
99 | export default abstract class PostgrestBuilder {
100 | constructor(builder: PostgrestBuilder) {
101 | //...
102 | if (builder.fetch) {
103 | this.fetch = builder.fetch;
104 | } else if (typeof fetch === 'undefined') {
105 | this.fetch = nodeFetch;
106 | } else {
107 | this.fetch = fetch;
108 | }
109 | }
110 |
111 | // [!code highlight:14]
112 | then(onfulfilled?, onrejected?): PromiseLike {
113 | // ...
114 | const _fetch = this.fetch;
115 | let res = _fetch(this.url.toString(), {
116 | method: this.method,
117 | headers: this.headers,
118 | body: JSON.stringify(this.body),
119 | signal: this.signal,
120 | }).then(async (res) => {
121 | // ...
122 | });
123 | // ...
124 | return res.then(onfulfilled, onrejected);
125 | }
126 | }
127 | ```
128 |
129 | 실제 네트워크 요청은 `then` 메서드에서 이루어짐을 확인할 수 있었습니다.
130 | 생성자에서 supabase를 사용하는 환경별로 `fetch` 함수를 설정해주는 것도
131 | 인상깊네요.
132 |
133 | ## 결론
134 |
135 | await의 대상이 모두 프로미스 객체여야한다는 고정관념을 버려야겠습니다. 아래
136 | 코드도 잘 동작합니다.
137 |
138 | ```js
139 | const obj = {
140 | then(resolve, reject) {
141 | resolve('hello');
142 | },
143 | };
144 |
145 | async function test() {
146 | const res = await obj;
147 | console.log(res);
148 | }
149 |
150 | test();
151 | ```
152 |
153 | mdn의
154 | [Promise](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise)
155 | 문서에도 참고할만한 내용이 있어서 가져왔어요:
156 |
157 | > JavaScript 생태계는 프로미스가 언어의 일부가 되기 훨씬 전부터 여러 가지
158 | > 프로미스 구현을 만들어왔습니다. 내부적으로 다르게 표현되기는 하지만, 최소한
159 | > 모든 프로미스와 유사한 객체는 Thenable 인터페이스를 구현합니다. thenable은 두
160 | > 개의 콜백(하나는 프로미스가 이행될 때, 다른 하나는 거부될 때)과 함께 호출되는
161 | > .then() 메서드를 구현합니다. 프로미스 또한 thenable입니다.
162 | >
163 | > 기존 프로미스 구현과 상호 운용하기 위해 언어에서는 프로미스 대신 thenables을
164 | > 사용할 수 있습니다.
165 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { routing } from '@/i18n/routing';
2 | import createMiddleware from 'next-intl/middleware';
3 |
4 | export default createMiddleware(routing);
5 |
6 | export const config = {
7 | // Match all pathnames except for
8 | // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
9 | // - … the ones containing a dot (e.g. `favicon.ico`)
10 | matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
11 | };
12 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import bundleAnalyzer from '@next/bundle-analyzer';
2 | import createMDX from '@next/mdx';
3 | import rehypeShiki from '@shikijs/rehype';
4 | import {
5 | transformerNotationDiff,
6 | transformerNotationFocus,
7 | transformerNotationHighlight,
8 | } from '@shikijs/transformers';
9 | import createNextIntlPlugin from 'next-intl/plugin';
10 | import rehypeKatex from 'rehype-katex';
11 | import rehypeSlug from 'rehype-slug';
12 | import remarkGfm from 'remark-gfm';
13 | import remarkMath from 'remark-math';
14 |
15 | const nextConfig = {
16 | images: {
17 | remotePatterns: [
18 | {
19 | protocol: 'http',
20 | hostname: 'localhost',
21 | },
22 | {
23 | protocol: 'https',
24 | hostname: 'yeolyi.com',
25 | },
26 | {
27 | protocol: 'https',
28 | hostname: 'jfzhtdcyaqwgugipnmhs.supabase.co',
29 | },
30 | ],
31 | },
32 | pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
33 | transpilePackages: ['shiki', '@shikijs/rehype'],
34 | eslint: { ignoreDuringBuilds: true },
35 | experimental: {
36 | serverActions: { bodySizeLimit: '2mb' },
37 | },
38 | serverExternalPackages: ['snoowrap'],
39 | };
40 |
41 | const withMDX = createMDX({
42 | options: {
43 | remarkPlugins: [remarkGfm, remarkMath],
44 | rehypePlugins: [
45 | rehypeSlug,
46 | rehypeKatex,
47 | [
48 | rehypeShiki,
49 | {
50 | inline: 'tailing-curly-colon',
51 | theme: 'one-dark-pro',
52 | transformers: [
53 | transformerNotationHighlight(),
54 | transformerNotationFocus(),
55 | transformerNotationDiff(),
56 | ],
57 | },
58 | ],
59 | ],
60 | },
61 | });
62 |
63 | const withNextIntl = createNextIntlPlugin();
64 | const withBundleAnalyzer = bundleAnalyzer({
65 | enabled: process.env.ANALYZE === 'true',
66 | });
67 |
68 | export default withMDX(withNextIntl(withBundleAnalyzer(nextConfig)));
69 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blog",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "analyze": "ANALYZE=true next build",
9 | "start": "next start",
10 | "prepare": "husky",
11 | "knip": "knip",
12 | "db-type": "supabase gen types typescript --project-id jfzhtdcyaqwgugipnmhs > database.types.ts"
13 | },
14 | "dependencies": {
15 | "@mdx-js/loader": "^3.1.0",
16 | "@mdx-js/react": "^3.1.0",
17 | "@next/bundle-analyzer": "^15.4.0-canary.51",
18 | "@next/mdx": "^15.2.4",
19 | "@shikijs/transformers": "^3.4.2",
20 | "@sparticuz/chromium": "135.0.0-next.3",
21 | "@supabase/supabase-js": "^2.49.4",
22 | "@tailwindcss/postcss": "^4.1.3",
23 | "@tailwindcss/typography": "^0.5.16",
24 | "@vercel/analytics": "^1.5.0",
25 | "@xyflow/react": "^12.6.0",
26 | "canvas-confetti": "^1.9.3",
27 | "clsx": "^2.1.1",
28 | "dayjs": "^1.11.13",
29 | "es-toolkit": "^1.35.0",
30 | "jotai": "^2.12.3",
31 | "jotai-effect": "^2.0.2",
32 | "lucide-react": "^0.487.0",
33 | "masonic": "^4.1.0",
34 | "medium-zoom": "^1.1.0",
35 | "next": "^15.3.1",
36 | "next-intl": "^4.0.2",
37 | "postcss": "^8.5.3",
38 | "prettier": "^3.5.3",
39 | "puppeteer": "^24.8.2",
40 | "puppeteer-core": "24.7.2",
41 | "radix-ui": "^1.1.3",
42 | "react": "^19.0.0",
43 | "react-dom": "^19.0.0",
44 | "react-toastify": "^11.0.5",
45 | "rehype-katex": "^7.0.1",
46 | "rehype-slug": "^6.0.0",
47 | "remark-gfm": "^4.0.1",
48 | "remark-math": "^6.0.0",
49 | "resend": "^4.5.1",
50 | "sharp": "^0.34.1",
51 | "swr": "^2.3.3",
52 | "tailwindcss": "^4.1.3",
53 | "uuid": "^11.1.0",
54 | "zustand": "^5.0.5"
55 | },
56 | "devDependencies": {
57 | "@biomejs/biome": "1.9.4",
58 | "@shikijs/rehype": "^3.4.2",
59 | "@types/canvas-confetti": "^1.9.0",
60 | "@types/mdx": "^2.0.13",
61 | "@types/node": "^20.17.48",
62 | "@types/react": "^19",
63 | "@types/react-dom": "^19",
64 | "husky": "^9.1.7",
65 | "knip": "^5.56.0",
66 | "lint-staged": "^15.5.1",
67 | "typescript": "^5.8.3"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | },
5 | };
6 | export default config;
7 |
--------------------------------------------------------------------------------
/public/cs/and-or-not/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/public/cs/and-or-not/og.png
--------------------------------------------------------------------------------
/public/cs/og1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/public/cs/og1.png
--------------------------------------------------------------------------------
/public/cs/shannon38.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/public/cs/shannon38.pdf
--------------------------------------------------------------------------------
/public/cs/zero-and-one/audio.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yeolyi/blog/3512ff96fc7bd42b6bf0f700a0635bcbce0e9491/public/cs/zero-and-one/audio.wav
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/googleb362144df7f7ef49.html:
--------------------------------------------------------------------------------
1 | google-site-verification: googleb362144df7f7ef49.html
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/pixelateWorker.js:
--------------------------------------------------------------------------------
1 | // 웹 워커 내에서 이미지 픽셀화 처리
2 | self.onmessage = (e) => {
3 | const { imageData, pixelCnt, width, height } = e.data;
4 |
5 | try {
6 | // 캔버스 생성 시도 (폴리필 포함)
7 | const canvas = self.OffscreenCanvas
8 | ? new OffscreenCanvas(width, height)
9 | : createFallbackCanvas(width, height);
10 |
11 | const ctx =
12 | canvas instanceof OffscreenCanvas
13 | ? canvas.getContext('2d', { willReadFrequently: true })
14 | : canvas.ctx;
15 |
16 | if (!ctx) {
17 | throw new Error('Canvas Context를 생성할 수 없습니다.');
18 | }
19 |
20 | // 이미지 데이터 설정
21 | const newImageData = new ImageData(
22 | new Uint8ClampedArray(imageData),
23 | width,
24 | height,
25 | );
26 |
27 | ctx.putImageData(newImageData, 0, 0);
28 |
29 | // 픽셀 블록 크기 계산
30 | const blockWidth = Math.ceil(width / pixelCnt);
31 | const blockHeight = Math.ceil(height / pixelCnt);
32 |
33 | // 이미지 데이터 가져오기
34 | const originalData = ctx.getImageData(0, 0, width, height).data;
35 |
36 | // 이미지 데이터 초기화
37 | ctx.clearRect(0, 0, width, height);
38 |
39 | // 각 픽셀 블록에 대해 평균 색상을 계산하고 그 블록을 해당 색상으로 채움
40 | for (let y = 0; y < pixelCnt; y++) {
41 | for (let x = 0; x < pixelCnt; x++) {
42 | let r = 0;
43 | let g = 0;
44 | let b = 0;
45 | let a = 0;
46 | let count = 0;
47 |
48 | // 각 블록의 시작/끝 좌표 계산 (이미지 가장자리 처리)
49 | const startX = x * blockWidth;
50 | const endX = Math.min((x + 1) * blockWidth, width);
51 | const startY = y * blockHeight;
52 | const endY = Math.min((y + 1) * blockHeight, height);
53 |
54 | // 블록 내의 모든 픽셀에 대한 색상의 합 계산
55 | for (let blockY = startY; blockY < endY; blockY++) {
56 | for (let blockX = startX; blockX < endX; blockX++) {
57 | const i = (blockY * width + blockX) * 4;
58 | r += originalData[i];
59 | g += originalData[i + 1];
60 | b += originalData[i + 2];
61 | a += originalData[i + 3];
62 | count++;
63 | }
64 | }
65 |
66 | // 색상 평균 계산
67 | if (count > 0) {
68 | r = Math.floor(r / count);
69 | g = Math.floor(g / count);
70 | b = Math.floor(b / count);
71 | a = Math.floor(a / count);
72 |
73 | // 해당 블록을 평균 색상으로 채움
74 | ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})`;
75 | ctx.fillRect(startX, startY, endX - startX, endY - startY);
76 | }
77 | }
78 | }
79 |
80 | // 픽셀화된 이미지를 Blob으로 변환 후 메인 스레드로 전송
81 | const blobPromise =
82 | canvas instanceof OffscreenCanvas
83 | ? canvas.convertToBlob({ type: 'image/png' })
84 | : canvas.convertToBlob({ type: 'image/png' });
85 |
86 | blobPromise
87 | .then((blob) => {
88 | const reader = new FileReader();
89 | reader.onloadend = () => {
90 | self.postMessage({ status: 'success', result: reader.result });
91 | };
92 | reader.readAsDataURL(blob);
93 | })
94 | .catch((error) => {
95 | self.postMessage({
96 | status: 'error',
97 | error: error.message || '이미지 변환 중 오류가 발생했습니다.',
98 | });
99 | });
100 | } catch (error) {
101 | self.postMessage({
102 | status: 'error',
103 | error: error.message || '처리 중 오류가 발생했습니다.',
104 | });
105 | }
106 | };
107 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /memes
3 |
4 | Sitemap: https://www.yeolyi.com/sitemap.xml
5 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/store/session.ts:
--------------------------------------------------------------------------------
1 | import supabase from '@/db';
2 | import { loginToDB, logoutFromDB } from '@/db/auth';
3 | import { getErrMessage } from '@/utils/string';
4 | import type { Session } from '@supabase/supabase-js';
5 | import { create } from 'zustand';
6 |
7 | type SessionState = {
8 | session: Session | null;
9 | isLoading: boolean;
10 |
11 | // 액션
12 | setSession: (session: Session | null) => void;
13 | setLoading: (isLoading: boolean) => void;
14 |
15 | // 인증 메서드
16 | login: () => Promise<{
17 | success: boolean;
18 | error: string;
19 | }>;
20 | logout: () => Promise<{
21 | success: boolean;
22 | error: string;
23 | }>;
24 | };
25 |
26 | export const useSessionStore = create((set) => ({
27 | session: null,
28 | isLoading: true,
29 |
30 | setSession: (session) => set({ session }),
31 | setLoading: (isLoading) => set({ isLoading }),
32 |
33 | login: async () => {
34 | try {
35 | set({ isLoading: true });
36 | await loginToDB();
37 | return { success: true, error: '' };
38 | } catch (error) {
39 | return {
40 | success: false,
41 | error: getErrMessage(error),
42 | };
43 | }
44 | },
45 |
46 | logout: async () => {
47 | try {
48 | await logoutFromDB();
49 | set({ session: null });
50 | return { success: true, error: '' };
51 | } catch (error) {
52 | console.error('로그아웃 오류:', error);
53 | return {
54 | success: false,
55 | error: getErrMessage(error),
56 | };
57 | }
58 | },
59 | }));
60 |
61 | // 인증 상태 변경 감지를 위한 리스너 설정
62 | export function initializeAuthListener() {
63 | const { setSession, setLoading } = useSessionStore.getState();
64 |
65 | // 인증 상태 변경 감지
66 | // 이걸 써야 탭 간에 상태 동기화가 가능하다.
67 | const {
68 | data: { subscription },
69 | } = supabase.auth.onAuthStateChange((event, session) => {
70 | setSession(session);
71 | setLoading(false);
72 | });
73 |
74 | // 구독 정리 함수 반환
75 | return () => {
76 | subscription.unsubscribe();
77 | };
78 | }
79 |
--------------------------------------------------------------------------------
/store/tempUser.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from 'uuid';
2 | import { create } from 'zustand';
3 | import { persist } from 'zustand/middleware';
4 |
5 | const TEMP_USER_ID_KEY = 'temp_user_id';
6 |
7 | type TempUserState = {
8 | id: string | null;
9 | getId: () => string;
10 | };
11 |
12 | export const useTempUserStore = create()(
13 | persist(
14 | (set, get) => ({
15 | id: null,
16 | getId: () => {
17 | const { id } = get();
18 | if (id) return id;
19 |
20 | const newId = uuidv4();
21 | set({ id: newId });
22 | return newId;
23 | },
24 | }),
25 | {
26 | name: TEMP_USER_ID_KEY,
27 | },
28 | ),
29 | );
30 |
--------------------------------------------------------------------------------
/swr/auth.ts:
--------------------------------------------------------------------------------
1 | import { getProfileFromDB } from '@/db/auth';
2 | import { useSessionStore } from '@/store/session';
3 | import { profileKey } from '@/swr/key';
4 | import useSWR from 'swr';
5 |
6 | export const useProfile = () => {
7 | const session = useSessionStore((state) => state.session);
8 | return useSWR(session ? profileKey : null, getProfileFromDB);
9 | };
10 |
--------------------------------------------------------------------------------
/swr/comment.ts:
--------------------------------------------------------------------------------
1 | import { createCommentInDB } from '@/db/comment/create';
2 | import { deleteCommentFromDB } from '@/db/comment/delete';
3 | import { getCommentsFromDB, getEmojiCounts } from '@/db/comment/read';
4 | import { useSessionStore } from '@/store/session';
5 | import useSWR, { mutate } from 'swr';
6 | import { commentsKey, emojiKey } from './key';
7 |
8 | export function useEmojiComment(id: string) {
9 | const session = useSessionStore((state) => state.session);
10 | return useSWR([emojiKey(id), session], () => getEmojiCounts(id, session));
11 | }
12 |
13 | export const useComments = (id: string) => {
14 | return useSWR(commentsKey(id), () => getCommentsFromDB(id));
15 | };
16 |
17 | export const deleteComment = async (postId: string, commentId: string) => {
18 | await deleteCommentFromDB(commentId);
19 | mutate(commentsKey(postId));
20 | };
21 |
22 | export const createComment = async (
23 | id: string,
24 | content: string,
25 | profileId: string,
26 | ) => {
27 | await createCommentInDB(id, content, profileId);
28 | mutate(commentsKey(id));
29 | };
30 |
--------------------------------------------------------------------------------
/swr/key.ts:
--------------------------------------------------------------------------------
1 | export const profileKey = 'profile';
2 |
3 | export const emojiKey = (postId: string) => `emoji-${postId}`;
4 | export const commentsKey = (postId: string) => `comments-${postId}`;
5 |
6 | export const memesByTagKey = (tagId: string) => `memes-${tagId}`;
7 | export const tagsKey = 'tags';
8 | export const memeTagKey = (memeId: string) => `meme-tag-${memeId}`;
9 |
--------------------------------------------------------------------------------
/swr/meme.ts:
--------------------------------------------------------------------------------
1 | import { getMemesFromDB } from '@/db/meme/read';
2 | import { type UpdateMemeAtDBProps, updateMemeAtDB } from '@/db/meme/update';
3 | import {
4 | getMemeTagIdsAtDB,
5 | getMemeTagsAtDB,
6 | getTagsAtDB,
7 | } from '@/db/memeTag/read';
8 | import { memeTagKey, memesByTagKey, tagsKey } from '@/swr/key';
9 | import useSWR, { mutate } from 'swr';
10 |
11 | export const NO_TAG_ID = 'all';
12 |
13 | export const useMemes = (tagId: string) => {
14 | return useSWR(memesByTagKey(tagId), () =>
15 | getMemesFromDB(tagId === NO_TAG_ID ? undefined : tagId),
16 | );
17 | };
18 |
19 | export const useTags = () => {
20 | return useSWR(tagsKey, getTagsAtDB);
21 | };
22 |
23 | export const useMemeTagIds = (memeId: string) => {
24 | return useSWR(memeTagKey(memeId), () => getMemeTagIdsAtDB(memeId));
25 | };
26 |
27 | export const updateMeme = async (props: UpdateMemeAtDBProps) => {
28 | await updateMemeAtDB(props);
29 | mutate(tagsKey);
30 | for (const tagId of props.tags ?? []) {
31 | await mutate(memesByTagKey(tagId));
32 | await mutate(memeTagKey(tagId));
33 | await mutate(memesByTagKey(NO_TAG_ID));
34 | }
35 | await mutate(memesByTagKey(NO_TAG_ID));
36 | };
37 |
38 | export const useMemeTags = (memeId: string) => {
39 | return useSWR(memeTagKey(memeId), () => getMemeTagsAtDB(memeId));
40 | };
41 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | theme: {
4 | extend: {
5 | fontFamily: {
6 | sans: ['var(--font-ibm-plex-sans)'],
7 | },
8 | typography: {
9 | DEFAULT: {
10 | css: {
11 | pre: {
12 | borderRadius: '0',
13 | fontFamily: 'var(--font-monoplex-kr)',
14 | },
15 | '.highlighted': {
16 | backgroundColor: 'rgba(255, 255, 255, 0.1)',
17 | // tailwind typography 참고
18 | padding: '0 1.1428571em',
19 | margin: '0 -1.1428571em',
20 | width: 'calc(100% + 2.2857143em)',
21 | display: 'inline-block',
22 | },
23 | '.line.diff.remove': {
24 | backgroundColor: 'rgba(248,81,73,0.3)',
25 | position: 'relative',
26 | },
27 | '.line.diff.remove::before': {
28 | position: 'absolute',
29 | height: '100%',
30 | lineHeight: '100%',
31 | top: 0,
32 | color: 'white',
33 | content: '"-"',
34 | fontSize: '1rem',
35 | left: '0.2em',
36 | },
37 | '.line.diff.add': {
38 | backgroundColor: '#2ea04326',
39 | position: 'relative',
40 | },
41 | '.line.diff.add::before': {
42 | position: 'absolute',
43 | height: '100%',
44 | lineHeight: '100%',
45 | top: 0,
46 | color: 'white',
47 | content: '"+"',
48 | fontSize: '1rem',
49 | left: '0.2em',
50 | },
51 | '*': {
52 | wordBreak: 'keep-all',
53 | },
54 | h2: {
55 | scrollMarginTop: '10vh',
56 | },
57 | h3: {
58 | scrollMarginTop: '10vh',
59 | },
60 | 'code::before': {
61 | content: 'none',
62 | },
63 | 'code::after': {
64 | content: 'none',
65 | },
66 | code: {
67 | // backgroundColor: 'white',
68 | // color: 'black',
69 | fontWeight: 'inherit',
70 | // fontSize: 'inherit',
71 | },
72 | 'code span': {
73 | whiteSpace: 'pre-wrap',
74 | wordBreak: 'keep-all',
75 | },
76 | // 따옴표 제거
77 | 'blockquote p:first-of-type::before': {
78 | content: 'none',
79 | },
80 | 'blockquote p:last-of-type::after': {
81 | content: 'none',
82 | },
83 | 'a:hover': {
84 | '-webkit-text-stroke': '0.4px currentColor',
85 | },
86 | summary: {
87 | '&:hover': {
88 | cursor: 'pointer',
89 | },
90 | },
91 | },
92 | },
93 | },
94 | },
95 | },
96 | };
97 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | },
24 | "baseUrl": "."
25 | },
26 | "include": [
27 | "next-env.d.ts",
28 | "**/*.ts",
29 | "**/*.tsx",
30 | ".next/types/**/*.ts",
31 | "script/programmerhumor.js",
32 | "app/[locale]/(mdx)/post/react-internal/page.mdx",
33 | "next.config.mjs",
34 | "mdx/react-internal/Lane.js",
35 | "components/cs/flow/index.tsx",
36 | "tmp.js",
37 | "script/uploadAvif.js"
38 | ],
39 | "exclude": ["node_modules"]
40 | }
41 |
--------------------------------------------------------------------------------
/types/helper.types.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from '@/types/database.types';
2 |
3 | export type Tag = Database['public']['Tables']['tags']['Row'];
4 |
5 | export type Meme = Database['public']['Tables']['memes']['Row'];
6 |
7 | export type Comment =
8 | Database['public']['Functions']['get_comments_with_developer_number']['Returns'][number];
9 |
--------------------------------------------------------------------------------
/utils/array.ts:
--------------------------------------------------------------------------------
1 | export function shuffled(array: T[]): T[] {
2 | const result = [...array];
3 | let currentIndex = result.length;
4 |
5 | while (currentIndex !== 0) {
6 | const randomIndex = Math.floor(Math.random() * currentIndex);
7 | currentIndex--;
8 |
9 | // And swap it with the current element.
10 | [result[currentIndex], result[randomIndex]] = [
11 | result[randomIndex],
12 | result[currentIndex],
13 | ];
14 | }
15 |
16 | return result;
17 | }
18 |
--------------------------------------------------------------------------------
/utils/confetti.ts:
--------------------------------------------------------------------------------
1 | import _confetti from 'canvas-confetti';
2 |
3 | export const confetti = (opts?: _confetti.Options) => {
4 | function fire(particleRatio: number, opts2: _confetti.Options) {
5 | _confetti({
6 | particleCount: Math.floor((window.innerWidth / 2.5) * particleRatio),
7 | colors: [
8 | '#D94773',
9 | '#D983A6',
10 | '#DB94BE',
11 | '#DFB0D3',
12 | '#E4D0ED',
13 | '#EEE6FB',
14 | ],
15 | disableForReducedMotion: true,
16 | ...opts2,
17 | ...opts,
18 | });
19 | }
20 |
21 | fire(0.25, {
22 | spread: 26,
23 | startVelocity: 55,
24 | });
25 | fire(0.2, {
26 | spread: 60,
27 | });
28 | fire(0.35, {
29 | spread: 100,
30 | decay: 0.91,
31 | scalar: 0.8,
32 | });
33 | fire(0.1, {
34 | spread: 120,
35 | startVelocity: 25,
36 | decay: 0.92,
37 | scalar: 1.2,
38 | });
39 | fire(0.1, {
40 | spread: 120,
41 | startVelocity: 45,
42 | });
43 | };
44 |
--------------------------------------------------------------------------------
/utils/error.ts:
--------------------------------------------------------------------------------
1 | import { getErrMessage } from '@/utils/string';
2 |
3 | type ServerActionResult =
4 | | {
5 | success: true;
6 | value: T;
7 | }
8 | | {
9 | success: false;
10 | value: string;
11 | };
12 |
13 | export function wrapServerAction(
14 | action: () => Promise,
15 | ): () => Promise>;
16 | export function wrapServerAction(
17 | action: (props: T) => Promise,
18 | ): (props: T) => Promise>;
19 | export function wrapServerAction(
20 | action: (props?: T) => Promise,
21 | ): (props?: T) => Promise> {
22 | return async (props?: T) => {
23 | try {
24 | const value = await action(props);
25 | return {
26 | success: true,
27 | value,
28 | };
29 | } catch (error) {
30 | console.error(error);
31 | return {
32 | success: false,
33 | value: getErrMessage(error),
34 | };
35 | }
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/utils/path.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 | import path from 'node:path';
3 | import type { Locale } from 'next-intl';
4 |
5 | const fetchPostIds = async (locale: Locale, subDir?: string) => {
6 | const postsDirectory = path.join(process.cwd(), 'mdx', subDir ?? '');
7 | const directories = fs.readdirSync(postsDirectory, { withFileTypes: true });
8 |
9 | return directories
10 | .filter((dirent) => dirent.isDirectory())
11 | .filter((dirent) => {
12 | const dirPath = path.join(postsDirectory, dirent.name);
13 | const files = fs.readdirSync(dirPath);
14 | return files.includes(`${locale}.mdx`);
15 | })
16 | .map((dirent) => dirent.name);
17 | };
18 |
19 | export const getPostIds = async (locale: Locale, subDir?: string) => {
20 | return await fetchPostIds(locale, subDir);
21 | };
22 |
--------------------------------------------------------------------------------
/utils/string.ts:
--------------------------------------------------------------------------------
1 | export const saveJSONToFile = (json: string, filename: string) => {
2 | const blob = new Blob([json], { type: 'application/json' });
3 | const url = URL.createObjectURL(blob);
4 | const a = document.createElement('a');
5 | a.href = url;
6 | a.download = filename;
7 | a.click();
8 | a.remove();
9 | URL.revokeObjectURL(url);
10 | };
11 |
12 | export const selectJSONFromFile = (): Promise => {
13 | return new Promise((resolve, reject) => {
14 | const input = document.createElement('input');
15 | input.type = 'file';
16 | input.accept = '.json';
17 |
18 | input.onchange = (e) => {
19 | const file = (e.target as HTMLInputElement).files?.[0];
20 | if (!file) return;
21 |
22 | const reader = new FileReader();
23 | reader.onload = (e) => resolve(e.target?.result as string);
24 | reader.onerror = reject;
25 | reader.readAsText(file);
26 | };
27 |
28 | input.click();
29 | input.remove();
30 | });
31 | };
32 |
33 | export const getErrMessage = (error: unknown): string => {
34 | if (typeof error === 'string') return error;
35 | if (error instanceof Error) return error.message;
36 | return `알 수 없는 에러: ${String(error)}`;
37 | };
38 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "regions": ["icn1"]
3 | }
4 |
--------------------------------------------------------------------------------