diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..9c0dff2 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,38 @@ +module.exports = { + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint", + "eslint-plugin-tsdoc", + "tsdoc-require", + "prettier" + ], + "parserOptions": { + "project": "./tsconfig.json", + "tsconfigRootDir": __dirname, + "ecmaVersion": 2018, + "sourceType": "module" + }, + "env": { + "node": true + }, + "extends": [ + "next/core-web-vitals", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "plugin:@typescript-eslint/strict", + "plugin:prettier/recommended", + "prettier", + "plugin:storybook/recommended" + ], + "rules": { + // 'React' must be in scope when using JSX 에러 해결 (Next.js) + "react/react-in-jsx-scope": "off", + // ts파일에서 tsx구문 허용 (Next.js) + "react/jsx-filename-extension": [1, { "extensions": [".ts", ".tsx"] }], + // TS Doc 주석 규약 위배 시 경고 표시 + "tsdoc/syntax": "warn", + // TS Doc 미작성 시 오류 + "tsdoc-require/require": 2 + } +}; \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54e375c --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage +*.snap +cypress/screenshots +cypress/downloads + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# documents +# /docs + +# WebStorm +.idea + +# storybook +/storybook-static \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..1a089f4 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no-install commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..d24fdfc --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..3462e8c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.19.0 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..042ff7b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 80, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "tabWidth": 2, + "bracketSpacing": true, + "endOfLine": "auto", + "useTabs": false, + "arrowParens": "avoid" +} \ No newline at end of file diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..54ccd9f --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,19 @@ +import type { StorybookConfig } from "@storybook/nextjs"; + +const config: StorybookConfig = { + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-onboarding", + "@storybook/addon-interactions", + ], + framework: { + name: "@storybook/nextjs", + options: {}, + }, + docs: { + autodocs: "tag", + }, +}; +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 0000000..ff58bbd --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,15 @@ +import type { Preview } from "@storybook/react"; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d15aa4 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# 0. Next.js 템플릿 +이 프로젝트는 개발에 필요한 일부 과정들에 대한 자동화 설정이 추가된 Next.js 템플릿입니다. +새로운 프론트엔드 프로젝트를 시작할 때 이 템플릿을 사용하세요. +베이스는 Next.js 기본 템플릿이지만, 다양한 3rd-party 도구들을 추가하였습니다. + +# 1. 사용하기 +이 프로젝트를 출발점으로 새 프론트엔드 프로젝트를 시작하기 위한 방법은 아래와 같습니다. + +## (1) 템플릿으로 새 프로젝트 생성하기 + +### 1) 절차 +GitHub 공식 문서에서 안내하는 절차는 다음 페이지에 소개되어 있습니다. +[-> creating-a-repository-from-a-template](https://docs.github.com/ko/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template) +위 문서에서 안내하는 절차에 따라 본 템플릿을 이용한 새 프로젝트를 생성하여 사용합니다. + +![use-this-template.png](assets/readme/use-this-template.png) + +## (2) 프로젝트 API 키 세팅하기 (WIP) + +### 1) 왜 필요한가요? +GitHub CI/CD 파이프라인 중 "릴리즈 자동화"를 실행하는 `semantic-release` 도구가 이를 필요로 합니다. +CI/CD 파이프라인 내에서 "릴리즈 자동화" 단계가 정상적으로 실행되기 위해서는 여기서 설명하는 세팅 절차를 진행해 주어야 합니다. + +### 2) 세팅 방법 +1. 프로젝트 좌측의 `Setting` > `Access Token` 메뉴로 들어갑니다. +2. `GITLAB_TOKEN` 이라는 이름으로 (`api`, `write_repository`) 2개의 권한이 부여된 토큰을 생성합니다. +3. 토큰을 복사해 둡니다. +4. 프로젝트 좌측의 `Setting` > `CI/CD` 메뉴로 들어갑니다. +5. `Variables` 에 `GITLAB_TOKEN` 이라는 키를 추가합니다. 값은 앞서 복사한 토큰을 붙여 넣습니다. + + +# 2. 개발 지원 기능 + +### 1) 코드 규약 검사 및 교정 +`ESLint` 와 `Prettier` 가 적용되어 있고 +`husky` 와 `lint-staged` 도구를 이용하여 커밋 단계에서 규약을 검사하고 자동으로 교정해 줍니다. +물론 커밋 단계에서 알아서 잡아주기는 하지만, +로컬 IDE 에서 관련 플러그인과 통합하여 개발 과정에서도 규약을 지켜가며 코드를 작성해 주세요. + +### 2) 주석 작성 의무화 및 규약 검사 +`ESLint` 에 `eslint-plugin-tsdoc` 과 `eslint-plugin-tsdoc-require` 을 적용하여 +모듈 코드에서 export 하는 객체에 대한 주석 작성을 의무화하였고, +작성된 주석에 대한 규약 검사 기능을 추가하였습니다. + +### 3) 커밋 메세지 규약 검사 및 교정 +`husky` 와 `commitlint` 도구를 이용하여 커밋 직전에 규약을 검사합니다. +검사 결과 정해진 양식에 위배되는 사항이 발견될 경우, 커밋이 되지 않습니다. +커밋을 하기 위해서는 양식을 준수하여 다시 작성해야 합니다. + +### 4) Merge Request 및 Issue 작성 양식 제공 +.gitlab 디렉토리 아래 기본 작성 양식을 정의하는 .md 확장자 파일들이 들어 있습니다. +GitLab 은 이 디렉토리 내에 있는 .md 파일들을 인지합니다. +MR 및 Issue 작성 화면에서 Description 을 기입할 때 이 디렉토리 내에 있는 특정 템플릿을 선택하여 해당 템플릿 양식에 맞춰 내용을 작성할 수 있습니다. + +### 5) GitLab CI/CD 파이프라인 실행 설정 +GitLab 은 CI/CD 파이프라인 실행 기능을 지원합니다. +파이프라인에서 실행할 작업은 `.gitlab-ci.yml` 파일에 작성합니다. +이 프로젝트에서는 배포 단계에서 실행되어야 하는 최소한의 작업 단계를 정의하였습니다. +추가로 필요한 실행 단계들이 있다면 제공되는 파일을 베이스로 추가 정의하여 사용하면 됩니다. + +### 6) 단위 테스트 도구 +단위 테스트를 실행하기 위한 `Jest` 도구를 추가하였습니다. +* 로컬 개발 환경에서의 실시간 개발을 위한 watch 모드 지원은 물론, +* CI 환경에서의 배포 파이프라인의 일부로서 전체 테스트 케이스들을 실행하는 모드도 지원합니다. + +### 7) 코드 문서화 도구 +TypeScript 코드에 작성된 주석들을 html 형식 문서로 작성해 주는 기능을 지원합니다. +해당 기능은 프로젝트에 추가된 `TypeDoc` 도구를 이용합니다. + +### 8) 컴포넌트 문서화 도구 +작성된 컴포넌트의 스펙에 대한 인터랙티브 문서화 기능을 지원합니다. +해당 기능은 프로젝트에 추가된 `Storybook` 을 이용합니다. + +### 9) 국제화(i18n) +페이지에 표시할 메세지들을 구조적으로 관리하기 위한 `i18next` 도구를 지원합니다. +* 각 언어별로, 메세지 내용 카테고리별로, 메세지를 템플릿화하여 관리할 수 있으며, +* SSR, CSR 양쪽 환경을 모두 지원하도록 각 환경에서 사용될 i18next 인스턴스가 있습니다. + +# 3. 실행 환경 + +## (1) 런타임 (v18.19.0) +본 프로젝트 빌드는 Node.js `v18.19.0` 런타임을 이용하였습니다. +각 환경별 런타임 버전은 아래 파일에 지정되어 있습니다. +본 템플릿을 이용하여 생성된 프로젝트 또한 마찬가지로 해당 버전의 런타임에서 실행하여야 합니다. +* 개발 환경 : [.nvmrc](./.nvmrc) + +`nvm` 을 이용하는 경우 아래 명령어를 통해 해당 버전 런타임 사용을 활성화할 수 있습니다. +```shell +nvm use +``` + +## (2) 패키지 관리자 +본 프로젝트 빌드에는 [pnpm](https://pnpm.io/ko/) 패키지 관리자를 사용하였습니다. + +### 0) pnpm 사용 설정하기 +아래 명령어를 통해 pnpm 사용이 활성화 됩니다. +```shell +corepack enable + +# pnpm 활성화 확인 +pnpm --version # 8.14.1 +``` +### 1) 의존성 설치하기 +```shell +pnpm install +``` + +## (3) npm 명령어 일람 +### 1) 개발 서버 구동 +```shell +pnpm run dev +``` +### 2) 프로덕션 서버 빌드 +```shell +pnpm run build +``` +### 3) 프로덕션 서버 기동 +```shell +pnpm start +``` +### 4) Linter 검사 실행 +```shell +pnpm run lint +``` +### 5) 단위 테스트 실행 +```shell +# CI 환경 파이프라인에서 실행 +pnpm run test + +# 개발 환경 코드 수정 사항 실시간 반영하는 watch 모드 실행 +pnpm run test:watch +``` +### 6) 통합 테스트 실행 +```shell +# CI 환경 파이프라인에서 실행 +pnpm run e2e:headless + +# 개발 환경 코드 수정 사항 실시간 반영하는 서버 실행 +pnpm run e2e +``` +### 7) 코드 내 주석들을 html 형식 문서로 내보내기 +```shell +pnpm run typedoc +``` +### 8) Storybook 실행하기 +```shell +# html 형식 문서로 내보내기 +pnpm run build-storybook + +# 개발 환경 코드 수정 사항 실시간 반영하는 서버 실행 +pnpm run storybook +``` diff --git a/assets/readme/use-this-template.png b/assets/readme/use-this-template.png new file mode 100644 index 0000000..a3b55f6 Binary files /dev/null and b/assets/readme/use-this-template.png differ diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..3d88028 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +module.exports = { extends: ['@commitlint/config-conventional'] }; \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..16d0f35 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,213 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ +import type { Config } from "jest"; +import nextJest from "next/jest.js"; + +/** + * This transformer has all the necessary configuration options for Jest to work with Next.js. + * Under the hood, next/jest is automatically configuring Jest for you. + * @see https://nextjs.org/docs/pages/building-your-application/testing/jest + */ +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: "./", +}); + +/** + * CLI `npm init jest@latest` 명령어를 실행 결과러 생성된 설정 구성 + */ +const config: Config = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/v5/5fknkhpd2blcnghd613zz7mdcmnt_p/T/jest_orx36e", + + // Automatically clear mock calls, instances, contexts and results before every test + // clearMocks: false, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + setupFilesAfterEnv: ["/jest.setup.ts"], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "jsdom", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; + +// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async +export default createJestConfig(config); diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..1f5c26b --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,2 @@ +import "@testing-library/jest-dom"; +// import "@testing-library/jest-dom/extend-expect"; diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..767719f --- /dev/null +++ b/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..f45b657 --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "name": "nextjs-template-v14", + "version": "0.1.0", + "private": true, + "pnpm": { + "overrides": { + "@testing-library/user-event": "^14.5.2" + } + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "prepare": "husky install", + "typedoc": "npx typedoc", + "test": "jest", + "test:watch": "jest --watch", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "dependencies": { + "accept-language": "^3.0.18", + "i18next": "^23.7.16", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-resources-to-backend": "^1.2.0", + "next": "14.0.4", + "react": "^18", + "react-cookie": "^7.0.1", + "react-dom": "^18", + "react-i18next": "^14.0.0" + }, + "devDependencies": { + "@commitlint/cli": "^18.4.4", + "@commitlint/config-conventional": "^18.4.4", + "@storybook/addon-essentials": "^7.6.7", + "@storybook/addon-interactions": "^7.6.7", + "@storybook/addon-links": "^7.6.7", + "@storybook/addon-onboarding": "^1.0.10", + "@storybook/blocks": "^7.6.7", + "@storybook/nextjs": "^7.6.7", + "@storybook/react": "^7.6.7", + "@storybook/test": "^7.6.7", + "@testing-library/jest-dom": "^6.2.0", + "@testing-library/react": "^14.1.2", + "@types/jest": "^29.5.11", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@typescript-eslint/eslint-plugin": "^6.18.1", + "@typescript-eslint/parser": "^6.18.1", + "autoprefixer": "^10.0.1", + "eslint": "^8", + "eslint-config-next": "14.0.4", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.2", + "eslint-plugin-storybook": "^0.6.15", + "eslint-plugin-tsdoc": "^0.2.17", + "eslint-plugin-tsdoc-require": "^0.0.3", + "husky": "^8.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.2.0", + "postcss": "^8", + "prettier": "^3.1.1", + "start-server-and-test": "^2.0.3", + "storybook": "^7.6.7", + "tailwindcss": "^3.3.0", + "ts-node": "^10.9.2", + "typedoc": "^0.25.7", + "typescript": "^5" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "eslint", + "prettier" + ] + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..d2f8422 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/[lng]/client-page/page.tsx b/src/app/[lng]/client-page/page.tsx new file mode 100644 index 0000000..b9b7a52 --- /dev/null +++ b/src/app/[lng]/client-page/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Link from "next/link"; +import { useTranslation } from "@/app/i18n/client"; +import ClientLanguageSwitcher from "@/containers/LanguageSwitcher/client"; +import { type HTMLAttributes, useState } from "react"; +import { type NextPage } from "next"; + +/** + * 페이지 : /[lng] + * @param lang - URL Parameter 에 포함된 언어 + */ +const ClientPage: NextPage<{ + params: { lng: NonNullable["lang"]> }; +}> = ({ params: { lng } }) => { + const { t } = useTranslation(lng, "client-page"); + const [counter, setCounter] = useState(0); + return ( + <> +

{t("title")}

+

{t("counter", { count: counter })}

+
+ + +
+ + + + + + ); +}; +export default ClientPage; diff --git a/src/app/[lng]/layout.tsx b/src/app/[lng]/layout.tsx new file mode 100644 index 0000000..9123208 --- /dev/null +++ b/src/app/[lng]/layout.tsx @@ -0,0 +1,56 @@ +import type { Metadata, NextPage } from "next"; +import { type HTMLAttributes, type PropsWithChildren } from "react"; +import { Inter } from "next/font/google"; +import { dir } from "i18next"; +import { supportedLngs } from "@/app/i18n/settings"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +/** + * /[lng]/** 아래 모든 페이지에 적용될 레이아웃에 전달할 URL Parameter + */ +interface RootLayoutParams { + /** + * 디렉토리 구조에 지정된 [lng] 파라미터 + */ + lng: HTMLAttributes["lang"]; +} + +/** + * RootLayout 컴포넌트 props 목록 정의 + */ +type RootLayoutProps = PropsWithChildren<{ + /** + * URL Parameter + */ + params: RootLayoutParams; +}>; + +/** + * SSG 빌드할 페이지 route 파라미터 목록 정의 + */ +export const generateStaticParams = (): RootLayoutParams[] => { + return supportedLngs.map(lng => ({ lng })); +}; + +/** + * 페이지 전역 공통 레이아웃 + * @param children - 페이지에 표시할 내용 + * @param lang - URL Parameter 에 포함된 언어 + */ +const RootLayout: NextPage = ({ + children, + params: { lng }, +}) => { + return ( + + {children} + + ); +}; +export default RootLayout; diff --git a/src/app/[lng]/page.tsx b/src/app/[lng]/page.tsx new file mode 100644 index 0000000..b4fc258 --- /dev/null +++ b/src/app/[lng]/page.tsx @@ -0,0 +1,36 @@ +import { NextPage } from "next"; +import Link from "next/link"; +import type { HTMLAttributes } from "react"; +import { useTranslation } from "@/app/i18n"; +import LanguageSwitcher from "@/containers/LanguageSwitcher"; + +/** + * 페이지 : /[lng] + * @param lang - URL Parameter 에 포함된 언어 + */ +const Page: NextPage<{ + params: { lng: NonNullable["lang"]> }; +}> = async ({ params: { lng } }) => { + /* + i18next 인스턴스를 초기화 및 생성하고, 설정 구성 및 인스턴스 참조를 가져옵니다. + */ + const { t } = await useTranslation(lng, "home-page"); + /* + 컴포넌트 구조 + */ + return ( + <> +

{t("title")}

+
    +
  • + {t("to-second-page")} +
  • +
  • + {t("to-client-page")} +
  • +
+ + + ); +}; +export default Page; diff --git a/src/app/[lng]/second-page/page.tsx b/src/app/[lng]/second-page/page.tsx new file mode 100644 index 0000000..eea5197 --- /dev/null +++ b/src/app/[lng]/second-page/page.tsx @@ -0,0 +1,30 @@ +import { NextPage } from "next"; +import Link from "next/link"; +import type { HTMLAttributes } from "react"; +import { useTranslation } from "@/app/i18n"; +import { fallbackLng } from "@/app/i18n/settings"; +import LanguageSwitcher from "@/containers/LanguageSwitcher"; + +/** + * 페이지 : /[lng]/second-page + * @param lang - URL Parameter 에 포함된 언어 + */ +const Page: NextPage<{ + params: { lng: HTMLAttributes["lang"] }; +}> = async ({ params: { lng = fallbackLng } }) => { + /* + i18next 인스턴스를 초기화 및 생성하고, 설정 구성 및 인스턴스 참조를 가져옵니다. + */ + const { t } = await useTranslation(lng, "second-page"); + /* + 컴포넌트 구조 + */ + return ( + <> +

{t("title")}

+ {t("back-to-home")} + + + ); +}; +export default Page; diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..fd81e88 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} diff --git a/src/app/i18n/client.ts b/src/app/i18n/client.ts new file mode 100644 index 0000000..ce02db8 --- /dev/null +++ b/src/app/i18n/client.ts @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState } from "react"; +import i18next from "i18next"; +import { + initReactI18next, + useTranslation as useTranslationOrg, +} from "react-i18next"; +import { useCookies } from "react-cookie"; +import resourcesToBackend from "i18next-resources-to-backend"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { + getOptions, + supportedLngs, + languageCookieName, + fallbackLng, + defaultNS, +} from "./settings"; + +const runsOnServerSide = typeof window === "undefined"; + +/* + i18next 객체를 초기화합니다. + 이 과정은 클라이언트 어플리케이션이 마운트될 때 최초 1 회만 실행되어야 합니다. + */ +void i18next + .use(initReactI18next) + .use(LanguageDetector) + .use( + resourcesToBackend( + (language: string, namespace: string) => + import(`./locales/${language}/${namespace}.json`), + ), + ) + .init({ + ...getOptions(fallbackLng, defaultNS), + lng: undefined, // let detect the language on client side + detection: { + order: ["path", "htmlTag", "cookie", "navigator"], + }, + preload: runsOnServerSide ? supportedLngs : [], + }); + +/** + * (CSR 컴포넌트) Hook 을 호출한 위치에서 i18next 인스턴스를 초기화 및 생성하고, 설정 구성 및 인스턴스 참조를 반환합니다. + * @param lng - 언어 설정 + * @param ns - 네임스페이스 설정 + * @param options - 인스턴스를 이용한 t() 함수를 + * @returns 설정 구성 및 인스턴스 참조를 반환합니다. + */ +export function useTranslation( + lng: Parameters< + ReturnType["i18n"]["changeLanguage"] + >[0], + ns: Parameters[0], + options?: Parameters[1], +) { + const [cookies, setCookie] = useCookies([languageCookieName]); + const ret = useTranslationOrg(ns, options); + const { i18n } = ret; + if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) { + void i18n.changeLanguage(lng); + } else { + } + const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage); + useEffect(() => { + if (activeLng === i18n.resolvedLanguage) return; + setActiveLng(i18n.resolvedLanguage); + }, [activeLng, i18n.resolvedLanguage]); + useEffect(() => { + if (!lng || i18n.resolvedLanguage === lng) return; + void i18n.changeLanguage(lng); + }, [lng, i18n]); + useEffect(() => { + if (cookies.i18next === lng) return; + setCookie(languageCookieName, lng, { path: "/" }); + }, [lng, setCookie, cookies.i18next]); + return ret; +} diff --git a/src/app/i18n/index.ts b/src/app/i18n/index.ts new file mode 100644 index 0000000..8eda13e --- /dev/null +++ b/src/app/i18n/index.ts @@ -0,0 +1,64 @@ +import { createInstance } from "i18next"; +import resourcesToBackend from "i18next-resources-to-backend"; +import { initReactI18next } from "react-i18next/initReactI18next"; +import { getOptions } from "./settings"; + +/** + * i18next 인스턴스를 초기화하고 참조를 반환하는 함수 타입 + */ +type initI18Next = ( + lng: Parameters[0], + ns: Parameters[1], +) => Promise>; + +/** + * i18next 인스턴스를 초기화하고 참조를 반환합니다. + * @param lng - 언어 설정 + * @param ns - 네임스페이스 설정 + */ +const initI18next: initI18Next = async (lng, ns) => { + const i18nInstance = createInstance(); + await i18nInstance + .use(initReactI18next) + .use( + resourcesToBackend( + ( + language: Parameters[0], + namespace: Parameters[1], + ) => import(`./locales/${language}/${namespace}.json`), + ), + ) + .init(getOptions(lng, ns)); + return i18nInstance; +}; + +/** + * 호출한 위치에서 i18next 인스턴스를 초기화 및 생성하고, 설정 구성 및 인스턴스 참조를 반환하는 Hook 타입 + */ +type UseTranslation = ( + lng: Parameters[0], + ns: Parameters[1], + options?: { keyPrefix?: string }, +) => Promise<{ + t: ReturnType["t"]; + i18n: ReturnType; +}>; + +/** + * Hook 을 호출한 위치에서 i18next 인스턴스를 초기화 및 생성하고, 설정 구성 및 인스턴스 참조를 반환합니다. + * @param lng - 언어 설정 + * @param ns - 네임스페이스 설정 + * @param options - 인스턴스를 이용한 t() 함수를 + * @returns 설정 구성 및 인스턴스 참조를 반환합니다. + */ +export const useTranslation: UseTranslation = async (lng, ns, options = {}) => { + const i18nextInstance = await initI18next(lng, ns); + return { + t: i18nextInstance.getFixedT( + lng, + ns, // Array.isArray(ns) ? ns[0] : ns + options.keyPrefix, + ), + i18n: i18nextInstance, + }; +}; diff --git a/src/app/i18n/locales/en/client-page.json b/src/app/i18n/locales/en/client-page.json new file mode 100644 index 0000000..85b8188 --- /dev/null +++ b/src/app/i18n/locales/en/client-page.json @@ -0,0 +1,7 @@ +{ + "title": "Client page", + "counter_one": "one selected", + "counter_other": "{{count}} selected", + "counter_zero": "none selected", + "back-to-home": "Back to home" +} \ No newline at end of file diff --git a/src/app/i18n/locales/en/home-page.json b/src/app/i18n/locales/en/home-page.json new file mode 100644 index 0000000..7ef0ea1 --- /dev/null +++ b/src/app/i18n/locales/en/home-page.json @@ -0,0 +1,5 @@ +{ + "title": "Hi there!", + "to-second-page": "To second page", + "to-client-page": "To Client Page" +} \ No newline at end of file diff --git a/src/app/i18n/locales/en/language-switcher.json b/src/app/i18n/locales/en/language-switcher.json new file mode 100644 index 0000000..cc0af06 --- /dev/null +++ b/src/app/i18n/locales/en/language-switcher.json @@ -0,0 +1,3 @@ +{ + "languageSwitcher": "Switch from <1>{{lng}} to: " +} \ No newline at end of file diff --git a/src/app/i18n/locales/en/second-page.json b/src/app/i18n/locales/en/second-page.json new file mode 100644 index 0000000..8482d42 --- /dev/null +++ b/src/app/i18n/locales/en/second-page.json @@ -0,0 +1,4 @@ +{ + "title": "Hi from second page!", + "back-to-home": "Back to home" +} \ No newline at end of file diff --git a/src/app/i18n/locales/ko/client-page.json b/src/app/i18n/locales/ko/client-page.json new file mode 100644 index 0000000..63ab2f6 --- /dev/null +++ b/src/app/i18n/locales/ko/client-page.json @@ -0,0 +1,7 @@ +{ + "title": "클라이언트 페이지", + "counter_one": "하나 선택됨", + "counter_other": "{{count}} 선택됨", + "counter_zero": "선택 없음", + "back-to-home": "홈으로 돌아가기" +} \ No newline at end of file diff --git a/src/app/i18n/locales/ko/home-page.json b/src/app/i18n/locales/ko/home-page.json new file mode 100644 index 0000000..4615d6f --- /dev/null +++ b/src/app/i18n/locales/ko/home-page.json @@ -0,0 +1,5 @@ +{ + "title": "안녕하세요 !", + "to-second-page": "세컨드 페이지로", + "to-client-page": "클라이언트 페이지로" +} \ No newline at end of file diff --git a/src/app/i18n/locales/ko/language-switcher.json b/src/app/i18n/locales/ko/language-switcher.json new file mode 100644 index 0000000..dbfdbcf --- /dev/null +++ b/src/app/i18n/locales/ko/language-switcher.json @@ -0,0 +1,3 @@ +{ + "languageSwitcher": "언어 설정을 <1>{{lng}} 에서 다음 언어로 변경합니다 : " +} \ No newline at end of file diff --git a/src/app/i18n/locales/ko/second-page.json b/src/app/i18n/locales/ko/second-page.json new file mode 100644 index 0000000..f652bb2 --- /dev/null +++ b/src/app/i18n/locales/ko/second-page.json @@ -0,0 +1,4 @@ +{ + "title": "세컨드 페이지입니다!", + "back-to-home": "홈으로" +} \ No newline at end of file diff --git a/src/app/i18n/settings.ts b/src/app/i18n/settings.ts new file mode 100644 index 0000000..6766b5d --- /dev/null +++ b/src/app/i18n/settings.ts @@ -0,0 +1,51 @@ +import { type HTMLAttributes } from "react"; +import { createInstance } from "i18next"; + +/** + * 태그 'lang' attr 타입 + */ +type HTMLLanguage = HTMLAttributes["lang"]; + +/** + * i18next config 데이터 구조 타입 + */ +type I18nConfig = Parameters["init"]>[0]; + +/** + * 기본 언어 + */ +export const fallbackLng = "ko"; + +/** + * 지원 언어 목록 + */ +export const supportedLngs: NonNullable[] = [fallbackLng, "en"]; + +/** + * 언어 정보 담을 쿠키 이름 + */ +export const languageCookieName = "i18next"; + +/** + * 기본 네임스페이스 + */ +export const defaultNS = "translation"; + +/** + * 전달받은 언어 및 네임스페이스를 반영한 config 구성 객체를 반환합니다. + * @param lng - 언어 설정 + * @param ns - 네임스페이스 설정 + */ +export const getOptions: ( + lng: NonNullable, + ns: string, +) => I18nConfig = (lng = fallbackLng, ns = defaultNS) => { + return { + supportedLngs, + fallbackLng, + lng, + fallbackNS: defaultNS, + defaultNS, + ns, + }; +}; diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..32c055a --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { type ReactNode } from "react"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +/** + * 페이지 전역 공통 레이아웃 + * @param children - 페이지에 표시할 내용 + */ +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..e1ee9ed --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,116 @@ +import Image from "next/image"; + +/** + * 페이지 : / + */ +export default function Home() { + return ( +
+
+

+ Get started by editing  + src/app/page.tsx +

+ +
+ +
+ Next.js Logo +
+ + +
+ ); +} diff --git a/src/components/atoms/LanguageSwitchPanel/index.tsx b/src/components/atoms/LanguageSwitchPanel/index.tsx new file mode 100644 index 0000000..0e1b0e2 --- /dev/null +++ b/src/components/atoms/LanguageSwitchPanel/index.tsx @@ -0,0 +1,58 @@ +import { type FC } from "react"; +import Link from "next/link"; +import { Trans } from "react-i18next/TransWithoutContext"; +import { supportedLngs } from "@/app/i18n/settings"; +import { type useTranslation } from "@/app/i18n"; + +/** + * LanguageSwitchPanel 컴포넌트 props 목록 정의 + */ +interface LanguageSwitchPanelProps { + /** + * 현재 사용 중인 언어 + */ + lng: Parameters[0]; + /** + * 현재 위치한 페이지의 pathname + */ + currentPathName: string; + /** + * 다국어 메세지 반환 함수 + */ + t: Awaited>["t"]; +} + +/** + * 언어 변경 패널 컴포넌트 + * @param lng - 현재 사용 중인 언어 + * @param currentPathName - 현재 위치한 페이지의 pathname + * @param t - 다국어 메세지 반환 함수 + */ +const LanguageSwitchPanel: FC = ({ + lng, + currentPathName, + t, +}) => { + /* + 컴포넌트 구조 + */ + return ( +
+ + {/* @ts-expect-error TS2353 */} + Switch from {{ lng }} to: + + {supportedLngs + .filter(l => lng !== l) + .map((l, index) => { + return ( + + {index > 0 && " or "} + {l} + + ); + })} +
+ ); +}; +export default LanguageSwitchPanel; diff --git a/src/containers/LanguageSwitcher/client.tsx b/src/containers/LanguageSwitcher/client.tsx new file mode 100644 index 0000000..366a769 --- /dev/null +++ b/src/containers/LanguageSwitcher/client.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useTranslation } from "@/app/i18n/client"; +import { type ComponentProps, type FC } from "react"; +import LanguageSwitchPanel from "@/components/atoms/LanguageSwitchPanel"; + +/** + * ClientLanguageSwitcher 컨테이너 props 목록 정의 + */ +interface ClientLanguageSwitcherProps { + /** + * 현재 사용 중인 언어 + */ + lng: ComponentProps["lng"]; + /** + * 현재 위치한 페이지의 pathname + */ + currentPathName: ComponentProps< + typeof LanguageSwitchPanel + >["currentPathName"]; +} + +/** + * (클라이언트 컴포넌트) 현재 표시 중인 언어 설정을 변경합니다. + * @param lng - 현재 사용 중인 언어 + * @param currentPathName - 현재 위치한 페이지의 pathname + */ +const ClientLanguageSwitcher: FC = ({ + lng, + currentPathName, +}) => { + /* + i18next 인스턴스를 초기화 및 생성하고, 설정 구성 및 인스턴스 참조를 가져옵니다. + */ + const { t } = useTranslation(lng, "language-switcher"); + /* + 컨테이너 구조 + */ + return ( + + ); +}; +export default ClientLanguageSwitcher; diff --git a/src/containers/LanguageSwitcher/index.tsx b/src/containers/LanguageSwitcher/index.tsx new file mode 100644 index 0000000..e6a485d --- /dev/null +++ b/src/containers/LanguageSwitcher/index.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "@/app/i18n"; +import { type ComponentProps, type FC } from "react"; +import LanguageSwitchPanel from "@/components/atoms/LanguageSwitchPanel"; + +/** + * Index 컨테이너 props 목록 정의 + */ +interface LanguageSwitcherProps { + /** + * 현재 사용 중인 언어 + */ + lng: ComponentProps["lng"]; + /** + * 현재 위치한 페이지의 pathname + */ + currentPathName: ComponentProps< + typeof LanguageSwitchPanel + >["currentPathName"]; +} + +/** + * (서버 컴포넌트) 현재 표시 중인 언어 설정을 변경합니다. + * @param lng - 현재 사용 중인 언어 + * @param currentPathName - 현재 위치한 페이지의 pathname + */ +const LanguageSwitcher: FC = async ({ + lng, + currentPathName, +}) => { + /* + i18next 인스턴스를 초기화 및 생성하고, 설정 구성 및 인스턴스 참조를 가져옵니다. + */ + const { t } = await useTranslation(lng, "language-switcher"); + /* + 컨테이너 구조 + */ + return ( + + ); +}; +export default LanguageSwitcher; diff --git a/src/middlewares/i18n.ts b/src/middlewares/i18n.ts new file mode 100644 index 0000000..9f93d15 --- /dev/null +++ b/src/middlewares/i18n.ts @@ -0,0 +1,54 @@ +import { type NextMiddleware, NextResponse } from "next/server"; +import acceptLanguage from "accept-language"; +import { + fallbackLng, + supportedLngs, + languageCookieName, +} from "@/app/i18n/settings"; + +/* + 지원할 언어 목록을 설정합니다. + */ +acceptLanguage.languages(supportedLngs); + +/** + * + */ +export const config = { + matcher: ["/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)"], +}; + +/** + * HTTP 요청을 처리하기 전에 언어 감지 로직을 실행하여 Request Handler 에게 전달해 줍니다. + * @param req - HTTP Request 정보 객체 + */ +export const middleware: NextMiddleware = req => { + let lang: ReturnType<(typeof acceptLanguage)["get"]> = null; + if (req.cookies.has(languageCookieName)) + lang = acceptLanguage.get(req.cookies.get(languageCookieName)?.value); + if (!lang) lang = acceptLanguage.get(req.headers.get("Accept-Language")); + if (!lang) lang = fallbackLng; + + // Redirect if lng in path is not supported + if ( + !supportedLngs.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`)) && + !req.nextUrl.pathname.startsWith("/_next") + ) { + return NextResponse.redirect( + new URL(`/${lang}${req.nextUrl.pathname}`, req.url), + ); + } + + const referer = req.headers.get("referer"); + if (referer) { + const refererUrl = new URL(referer); + const lngInReferer = supportedLngs.find(l => + refererUrl.pathname.startsWith(`/${l}`), + ); + const response = NextResponse.next(); + if (lngInReferer) response.cookies.set(languageCookieName, lngInReferer); + return response; + } + + return NextResponse.next(); +}; diff --git a/src/stories/Button.stories.ts b/src/stories/Button.stories.ts new file mode 100644 index 0000000..b6b36dc --- /dev/null +++ b/src/stories/Button.stories.ts @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Button } from "./Button"; + +/** + * More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export + */ +const meta = { + title: "Example/Button", + component: Button, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: "centered", + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ["autodocs"], + // More on argTypes: https://storybook.js.org/docs/api/argtypes + argTypes: { + backgroundColor: { control: "color" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const Primary: Story = { + args: { + primary: true, + label: "Button", + }, +}; + +export const Secondary: Story = { + args: { + label: "Button", + }, +}; + +export const Large: Story = { + args: { + size: "large", + label: "Button", + }, +}; + +export const Small: Story = { + args: { + size: "small", + label: "Button", + }, +}; + +export const Warning: Story = { + args: { + primary: true, + label: "Delete now", + backgroundColor: "red", + }, +}; diff --git a/src/stories/Button.tsx b/src/stories/Button.tsx new file mode 100644 index 0000000..98123a3 --- /dev/null +++ b/src/stories/Button.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import "./button.css"; + +interface ButtonProps { + /** + * Is this the principal call to action on the page? + */ + primary?: boolean; + /** + * What background color to use + */ + backgroundColor?: string; + /** + * How large should the button be? + */ + size?: "small" | "medium" | "large"; + /** + * Button contents + */ + label: string; + /** + * Optional click handler + */ + onClick?: () => void; +} + +/** + * Primary UI component for user interaction + */ +export const Button = ({ + primary = false, + size = "medium", + backgroundColor, + label, + ...props +}: ButtonProps) => { + const mode = primary + ? "storybook-button--primary" + : "storybook-button--secondary"; + return ( + + ); +}; diff --git a/src/stories/Configure.mdx b/src/stories/Configure.mdx new file mode 100644 index 0000000..055a3c5 --- /dev/null +++ b/src/stories/Configure.mdx @@ -0,0 +1,446 @@ +import { Meta } from "@storybook/blocks"; +import Image from "next/image"; + +import Github from "./assets/github.svg"; +import Discord from "./assets/discord.svg"; +import Youtube from "./assets/youtube.svg"; +import Tutorials from "./assets/tutorials.svg"; +import Styling from "./assets/styling.png"; +import Context from "./assets/context.png"; +import Assets from "./assets/assets.png"; +import Docs from "./assets/docs.png"; +import Share from "./assets/share.png"; +import FigmaPlugin from "./assets/figma-plugin.png"; +import Testing from "./assets/testing.png"; +import Accessibility from "./assets/accessibility.png"; +import Theming from "./assets/theming.png"; +import AddonLibrary from "./assets/addon-library.png"; + +export const RightArrow = () => + + + + + +
+
+ # Configure your project + + Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community. +
+
+
+ A wall of logos representing different styling technologies +

Add styling and CSS

+

Like with web applications, there are many ways to include CSS within Storybook. Learn more about setting up styling within Storybook.

+ Learn more +
+
+ An abstraction representing the composition of data for a component +

Provide context and mocking

+

Often when a story doesn't render, it's because your component is expecting a specific environment or context (like a theme provider) to be available.

+ Learn more +
+
+ A representation of typography and image assets +
+

Load assets and resources

+

To link static files (like fonts) to your projects and stories, use the + `staticDirs` configuration option to specify folders to load when + starting Storybook.

+ Learn more +
+
+
+
+
+
+ # Do more with Storybook + + Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs. +
+ +
+
+
+ A screenshot showing the autodocs tag being set, pointing a docs page being generated +

Autodocs

+

Auto-generate living, + interactive reference documentation from your components and stories.

+ Learn more +
+
+ A browser window showing a Storybook being published to a chromatic.com URL +

Publish to Chromatic

+

Publish your Storybook to review and collaborate with your entire team.

+ Learn more +
+
+ Windows showing the Storybook plugin in Figma +

Figma Plugin

+

Embed your stories into Figma to cross-reference the design and live + implementation in one place.

+ Learn more +
+
+ Screenshot of tests passing and failing +

Testing

+

Use stories to test a component in all its variations, no matter how + complex.

+ Learn more +
+
+ Screenshot of accessibility tests passing and failing +

Accessibility

+

Automatically test your components for a11y issues as you develop.

+ Learn more +
+
+ Screenshot of Storybook in light and dark mode +

Theming

+

Theme Storybook's UI to personalize it to your project.

+ Learn more +
+
+
+
+
+
+

Addons

+

Integrate your tools with Storybook to connect workflows.

+ Discover all addons +
+
+ Integrate your tools with Storybook to connect workflows. +
+
+ +
+
+ Github logo + Join our contributors building the future of UI development. + + Star on GitHub +
+
+ Discord logo +
+ Get support and chat with frontend developers. + + Join Discord server +
+
+
+ Youtube logo +
+ Watch tutorials, feature previews and interviews. + + Watch on YouTube +
+
+
+ A book +

Follow guided walkthroughs on for key workflows.

+ + Discover tutorials +
+
+ + diff --git a/src/stories/Header.stories.ts b/src/stories/Header.stories.ts new file mode 100644 index 0000000..cb5a414 --- /dev/null +++ b/src/stories/Header.stories.ts @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Header } from "./Header"; + +/** + * 헤더 컴포넌트 속성 정의 + */ +const meta = { + title: "Example/Header", + component: Header, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ["autodocs"], + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: "fullscreen", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LoggedIn: Story = { + args: { + user: { + name: "Jane Doe", + }, + }, +}; + +export const LoggedOut: Story = {}; diff --git a/src/stories/Header.tsx b/src/stories/Header.tsx new file mode 100644 index 0000000..6ce992c --- /dev/null +++ b/src/stories/Header.tsx @@ -0,0 +1,71 @@ +import React from "react"; + +import { Button } from "./Button"; +import "./header.css"; + +type User = { + name: string; +}; + +interface HeaderProps { + user?: User; + onLogin: () => void; + onLogout: () => void; + onCreateAccount: () => void; +} + +export const Header = ({ + user, + onLogin, + onLogout, + onCreateAccount, +}: HeaderProps) => ( +
+
+
+ + + + + + + +

Acme

+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+); diff --git a/src/stories/Page.stories.ts b/src/stories/Page.stories.ts new file mode 100644 index 0000000..f41c813 --- /dev/null +++ b/src/stories/Page.stories.ts @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { within, userEvent, expect } from "@storybook/test"; + +import { Page } from "./Page"; + +/** + * 페이지 컴포넌트 속성 정의 + */ +const meta = { + title: "Example/Page", + component: Page, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: "fullscreen", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LoggedOut: Story = {}; + +// More on interaction testing: https://storybook.js.org/docs/writing-tests/interaction-testing +export const LoggedIn: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const loginButton = canvas.getByRole("button", { name: /Log in/i }); + await expect(loginButton).toBeInTheDocument(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await userEvent.click(loginButton); // fixme: pnpm 아니어도 위의 ESLint 규칙 억제가 필요 없도록 개선해야 함 + await expect(loginButton).not.toBeInTheDocument(); + + const logoutButton = canvas.getByRole("button", { name: /Log out/i }); + await expect(logoutButton).toBeInTheDocument(); + }, +}; diff --git a/src/stories/Page.tsx b/src/stories/Page.tsx new file mode 100644 index 0000000..c97bc57 --- /dev/null +++ b/src/stories/Page.tsx @@ -0,0 +1,91 @@ +import React from "react"; + +import { Header } from "./Header"; +import "./page.css"; + +type User = { + name: string; +}; + +export const Page: React.FC = () => { + const [user, setUser] = React.useState(); + + return ( +
+
setUser({ name: "Jane Doe" })} + onLogout={() => setUser(undefined)} + onCreateAccount={() => setUser({ name: "Jane Doe" })} + /> + +
+

Pages in Storybook

+

+ We recommend building UIs with a{" "} + + component-driven + {" "} + process starting with atomic components and ending with pages. +

+

+ Render pages with mock data. This makes it easy to build and review + page states without needing to navigate to them in your app. Here are + some handy patterns for managing page data in Storybook: +

+
    +
  • + Use a higher-level connected component. Storybook helps you compose + such data from the “args” of child component stories +
  • +
  • + Assemble data in the page component from your services. You can mock + these services out using Storybook. +
  • +
+

+ Get a guided tutorial on component-driven development at{" "} + + Storybook tutorials + + . Read more in the{" "} + + docs + + . +

+
+ Tip Adjust the width of the canvas with + the{" "} + + + + + + Viewports addon in the toolbar +
+
+
+ ); +}; diff --git a/src/stories/assets/accessibility.png b/src/stories/assets/accessibility.png new file mode 100644 index 0000000..6ffe6fe Binary files /dev/null and b/src/stories/assets/accessibility.png differ diff --git a/src/stories/assets/accessibility.svg b/src/stories/assets/accessibility.svg new file mode 100644 index 0000000..a328883 --- /dev/null +++ b/src/stories/assets/accessibility.svg @@ -0,0 +1,5 @@ + + Accessibility + + + \ No newline at end of file diff --git a/src/stories/assets/addon-library.png b/src/stories/assets/addon-library.png new file mode 100644 index 0000000..95deb38 Binary files /dev/null and b/src/stories/assets/addon-library.png differ diff --git a/src/stories/assets/assets.png b/src/stories/assets/assets.png new file mode 100644 index 0000000..cfba681 Binary files /dev/null and b/src/stories/assets/assets.png differ diff --git a/src/stories/assets/avif-test-image.avif b/src/stories/assets/avif-test-image.avif new file mode 100644 index 0000000..530709b Binary files /dev/null and b/src/stories/assets/avif-test-image.avif differ diff --git a/src/stories/assets/context.png b/src/stories/assets/context.png new file mode 100644 index 0000000..e5cd249 Binary files /dev/null and b/src/stories/assets/context.png differ diff --git a/src/stories/assets/discord.svg b/src/stories/assets/discord.svg new file mode 100644 index 0000000..1204df9 --- /dev/null +++ b/src/stories/assets/discord.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/stories/assets/docs.png b/src/stories/assets/docs.png new file mode 100644 index 0000000..a749629 Binary files /dev/null and b/src/stories/assets/docs.png differ diff --git a/src/stories/assets/figma-plugin.png b/src/stories/assets/figma-plugin.png new file mode 100644 index 0000000..8f79b08 Binary files /dev/null and b/src/stories/assets/figma-plugin.png differ diff --git a/src/stories/assets/github.svg b/src/stories/assets/github.svg new file mode 100644 index 0000000..158e026 --- /dev/null +++ b/src/stories/assets/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/stories/assets/share.png b/src/stories/assets/share.png new file mode 100644 index 0000000..8097a37 Binary files /dev/null and b/src/stories/assets/share.png differ diff --git a/src/stories/assets/styling.png b/src/stories/assets/styling.png new file mode 100644 index 0000000..d341e82 Binary files /dev/null and b/src/stories/assets/styling.png differ diff --git a/src/stories/assets/testing.png b/src/stories/assets/testing.png new file mode 100644 index 0000000..d4ac39a Binary files /dev/null and b/src/stories/assets/testing.png differ diff --git a/src/stories/assets/theming.png b/src/stories/assets/theming.png new file mode 100644 index 0000000..1535eb9 Binary files /dev/null and b/src/stories/assets/theming.png differ diff --git a/src/stories/assets/tutorials.svg b/src/stories/assets/tutorials.svg new file mode 100644 index 0000000..4b2fc7c --- /dev/null +++ b/src/stories/assets/tutorials.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/stories/assets/youtube.svg b/src/stories/assets/youtube.svg new file mode 100644 index 0000000..33a3a61 --- /dev/null +++ b/src/stories/assets/youtube.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/stories/button.css b/src/stories/button.css new file mode 100644 index 0000000..dc91dc7 --- /dev/null +++ b/src/stories/button.css @@ -0,0 +1,30 @@ +.storybook-button { + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 700; + border: 0; + border-radius: 3em; + cursor: pointer; + display: inline-block; + line-height: 1; +} +.storybook-button--primary { + color: white; + background-color: #1ea7fd; +} +.storybook-button--secondary { + color: #333; + background-color: transparent; + box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; +} +.storybook-button--small { + font-size: 12px; + padding: 10px 16px; +} +.storybook-button--medium { + font-size: 14px; + padding: 11px 20px; +} +.storybook-button--large { + font-size: 16px; + padding: 12px 24px; +} diff --git a/src/stories/header.css b/src/stories/header.css new file mode 100644 index 0000000..d9a7052 --- /dev/null +++ b/src/stories/header.css @@ -0,0 +1,32 @@ +.storybook-header { + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + padding: 15px 20px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.storybook-header svg { + display: inline-block; + vertical-align: top; +} + +.storybook-header h1 { + font-weight: 700; + font-size: 20px; + line-height: 1; + margin: 6px 0 6px 10px; + display: inline-block; + vertical-align: top; +} + +.storybook-header button + button { + margin-left: 10px; +} + +.storybook-header .welcome { + color: #333; + font-size: 14px; + margin-right: 10px; +} diff --git a/src/stories/page.css b/src/stories/page.css new file mode 100644 index 0000000..098dad1 --- /dev/null +++ b/src/stories/page.css @@ -0,0 +1,69 @@ +.storybook-page { + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 24px; + padding: 48px 20px; + margin: 0 auto; + max-width: 600px; + color: #333; +} + +.storybook-page h2 { + font-weight: 700; + font-size: 32px; + line-height: 1; + margin: 0 0 4px; + display: inline-block; + vertical-align: top; +} + +.storybook-page p { + margin: 1em 0; +} + +.storybook-page a { + text-decoration: none; + color: #1ea7fd; +} + +.storybook-page ul { + padding-left: 30px; + margin: 1em 0; +} + +.storybook-page li { + margin-bottom: 8px; +} + +.storybook-page .tip { + display: inline-block; + border-radius: 1em; + font-size: 11px; + line-height: 12px; + font-weight: 700; + background: #e7fdd8; + color: #66bf3c; + padding: 4px 12px; + margin-right: 10px; + vertical-align: top; +} + +.storybook-page .tip-wrapper { + font-size: 13px; + line-height: 20px; + margin-top: 40px; + margin-bottom: 40px; +} + +.storybook-page .tip-wrapper svg { + display: inline-block; + height: 12px; + width: 12px; + margin-right: 4px; + vertical-align: top; + margin-top: 3px; +} + +.storybook-page .tip-wrapper svg path { + fill: #1ea7fd; +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..1af3b8f --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,20 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': + 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + }, + }, + }, + plugins: [], +} +export default config diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..18c1136 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/typedoc.js b/typedoc.js new file mode 100644 index 0000000..99866e0 --- /dev/null +++ b/typedoc.js @@ -0,0 +1,8 @@ +/** @type {import('typedoc').TypeDocOptions} **/ +module.exports = { + $schema: "https://typedoc.org/schema.json", + entryPoints: ["./src"], + entryPointStrategy: "expand", + exclude: "**/*+(index|.spec|.e2e).ts", + out: "./docs/code", +} \ No newline at end of file