chore: initial commit
Some checks failed
Chromatic / chromatic (push) Failing after 42s
CI / ci (push) Successful in 2m44s
CI / commitlint (push) Successful in 14s

This commit is contained in:
Théo LUDWIG 2024-07-24 12:35:33 +02:00
commit 3ae6d2fac3
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
119 changed files with 19677 additions and 0 deletions

38
.dockerignore Normal file
View File

@ -0,0 +1,38 @@
**/.git
**/.turbo
**/.next
**/out
**/dist
**/build
**/storybook-static
**/coverage
**/node_modules
# envs
.env
.env.production
.env.development
secrets
# IDEs and editors
.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
.vscode
# misc
.DS_Store
*.pem
Dockerfile
README.md
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

11
.editorconfig Normal file
View File

@ -0,0 +1,11 @@
# https://editorconfig.org/
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

1
.env.example Normal file
View File

@ -0,0 +1 @@
WEBSITE_PORT=5000

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

33
.github/workflows/chromatic.yml vendored Normal file
View File

@ -0,0 +1,33 @@
name: "Chromatic"
on:
push:
branches: [develop]
pull_request:
branches: [develop, staging, main]
jobs:
chromatic:
timeout-minutes: 30
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4.1.7"
with:
fetch-depth: 0
- uses: "pnpm/action-setup@v4.0.0"
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.3"
with:
node-version: "22.x"
cache: "pnpm"
- name: "Install dependencies"
run: "pnpm install --frozen-lockfile"
- name: "Run Chromatic"
uses: "chromaui/action@latest"
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: "apps/storybook"

42
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: "CI"
on:
push:
branches: [develop]
pull_request:
branches: [develop, staging, main]
jobs:
ci:
timeout-minutes: 30
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4.1.7"
- uses: "pnpm/action-setup@v4.0.0"
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.3"
with:
node-version: "22.x"
cache: "pnpm"
- name: "Install dependencies"
run: "pnpm install --frozen-lockfile"
# - name: "Install Playwright"
# run: "pnpm exec playwright install --with-deps"
- run: "node --run lint:editorconfig"
- run: "node --run lint:prettier"
- run: "node --run lint:eslint"
- run: "node --run lint:typescript"
- run: "node --run test"
- run: "node --run build"
commitlint:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4.1.7"
- uses: "wagoid/commitlint-github-action@v6.0.1"

48
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: "Release"
on:
push:
branches: [staging]
# branches: [main, staging]
jobs:
release:
timeout-minutes: 30
runs-on: "ubuntu-latest"
permissions:
contents: "write"
issues: "write"
pull-requests: "write"
id-token: "write"
steps:
- uses: "actions/checkout@v4.1.7"
with:
fetch-depth: 0
persist-credentials: false
- name: "Import GPG key"
uses: "crazy-max/ghaction-import-gpg@v6.0.0"
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
git_user_signingkey: true
git_commit_gpgsign: true
- uses: "pnpm/action-setup@v4.0.0"
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.3"
with:
node-version: "22.x"
cache: "pnpm"
- name: "Install dependencies"
run: "pnpm install --frozen-lockfile"
- name: "Release"
run: "node --run release"
env:
GH_URL: ${{ secrets.GH_URL }}
GH_PREFIX: ${{ secrets.GH_PREFIX }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GIT_COMMITTER_NAME: ${{ secrets.GIT_NAME }}
GIT_COMMITTER_EMAIL: ${{ secrets.GIT_EMAIL }}

53
.gitignore vendored Normal file
View File

@ -0,0 +1,53 @@
# dependencies
node_modules
.npm
package-lock.json
.pnpm-store
.pnp
.pnp.js
.yarn/install-state.gz
# testing
coverage
# production
.next/
out/
dist/
build/
# misc
.DS_Store
*.pem
.turbo
bin/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Storybook
*storybook.log
storybook-static
# IDEs and editors
.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# local env files
.env
.env.production
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# next-env.d.ts

1
.npmrc Normal file
View File

@ -0,0 +1 @@
save-exact = true

13
.prettierrc.json Executable file
View File

@ -0,0 +1,13 @@
{
"semi": false,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["classNames", "cva"],
"overrides": [
{
"files": "pnpm-lock.yaml",
"options": {
"rangeEnd": 0
}
}
]
}

40
.releaserc.json Normal file
View File

@ -0,0 +1,40 @@
{
"branches": ["main", { "name": "staging", "prerelease": true }],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec",
{
"prepareCmd": "replace-in-files --regex='version\": *\"[^\"]*' --replacement='\"version\": \"${nextRelease.version}\"' '**/package.json' '!**/node_modules/**'"
}
],
[
"@semantic-release/git",
{
"assets": [
"package.json",
"apps/*/package.json",
"packages/*/package.json"
],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
],
[
"@semantic-release/github",
{
"successComment": false,
"failComment": false
}
],
[
"@saithodev/semantic-release-backmerge",
{
"branches": [
{ "from": "main", "to": "develop" },
{ "from": "staging", "to": "develop" }
]
}
]
]
}

11
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"recommendations": [
"Vercel.turbo-vsc",
"editorconfig.editorconfig",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"mikestead.dotenv",
"ms-azuretools.vscode-docker"
]
}

43
.vscode/react.code-snippets vendored Normal file
View File

@ -0,0 +1,43 @@
{
"React Component": {
"scope": "typescriptreact",
"prefix": "rfc",
"body": [
"interface ${1:ComponentName}Props {}",
"",
"export const ${1:ComponentName}: React.FC<${1:ComponentName}Props> = () => {",
" return (",
" <div>",
" <h1>${1:ComponentName}</h1>",
" </div>",
" )",
"}",
"",
],
"description": "React Component",
},
"React Component Story": {
"scope": "typescriptreact",
"prefix": "rfcs",
"body": [
"import type { Meta, StoryObj } from \"@storybook/react\"",
"",
"import { ${1:ComponentName} as ${1:ComponentName}Component } from \"./${1:ComponentName}\"",
"",
"const meta = {",
" title: \"${1:ComponentName}\",",
" component: ${1:ComponentName}Component",
"} satisfies Meta<typeof ${1:ComponentName}Component>",
"",
"export default meta",
"",
"type Story = StoryObj<typeof meta>",
"",
"export const ${1:ComponentName}: Story = {",
" args: {}",
"}",
"",
],
"description": "React Component Story",
},
}

20
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.bracketPairColorization.enabled": true,
"editor.wordWrap": "on",
"prettier.configPath": ".prettierrc.json",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
"eslint.options": {
"ignorePath": ".gitignore"
},
"prettier.ignorePath": ".gitignore",
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
# MIT License
Copyright (c) Théo LUDWIG <contact@theoludwig.fr>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# Wikipedia Game Solver
> \[!IMPORTANT\]
> This project is a work in progress, at an early stage of development.
## About
The Wikipedia Game involves players competing to navigate from one [Wikipedia](https://en.wikipedia.org/) page to another using only internal links.
[**Wikipedia Game Solver**](https://wikipedia-game-solver.theoludwig.fr) is a tool that helps you find the shortest path between two Wikipedia pages, using only internal links, basically solving the Wikipedia Game for you.
Available online: <https://wikipedia-game-solver.theoludwig.fr>
> \[!NOTE\]
> The project is also a way to learn and experiment with a monorepo architecture, with [Turborepo](https://turbo.build/repo), and [TypeScript](https://www.typescriptlang.org/) as the main language.
>
> The project setup **can be used as a template/boilerplate for new projects**.
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org/) >= 22.0.0
- [pnpm](https://pnpm.io/) >= 9.5.0
### Installation
```sh
# Go to the project root
cd wikipedia-game-solver
# Configure environment variables
cp .env.example .env
cp apps/website/.env.example apps/website/.env
# Install dependencies
pnpm install --frozen-lockfile
# Install Playwright browser binaries and their dependencies (tests)
pnpm exec playwright install --with-deps
```
### Development
```sh
# Start the development server
node --run dev
# Lint
node --run lint:editorconfig
node --run lint:prettier
node --run lint:eslint
node --run lint:typescript
# Tests
node --run test
# Build
node --run build
```
### Production environment with [Docker](https://www.docker.com/)
```sh
# Setup and run all the services for you
docker compose up --build
```
#### Services started
`wikipedia-game-solver`: <http://127.0.0.1:5000>
## License
[MIT](./LICENSE)

20
TODO.md Normal file
View File

@ -0,0 +1,20 @@
# TODO
- [x] chore: initial commit (+ mirror on GitHub)
- [x] Deploy first staging version (v1.0.0-staging.1)
- [ ] Add docs to add locale/edit translations, create component, install a dependency in a package, create a new package, technology used, architecture, links where it's deployed, how to use/install for end users, how to update dependencies with `npx taze -l` etc.
- [ ] Implement Wikipedia Game Solver (`website`) with inputs, button to submit, and list all articles to go from one to another, or none if it is not possible
- [ ] v1.0.0-staging.2
- [ ] Implement CLI (`cli`)
- [ ] v1.0.0-staging.3
- [ ] Implement REST API (`api`) with JSON responses ([AdonisJS](https://adonisjs.com/))
- [ ] v1.0.0-staging.4
- [ ] v1.0.0
## Links
- <https://www.thewikigame.com/>
- How to get all URLs in a Wikipedia page: <https://stackoverflow.com/questions/14882571/how-to-get-all-urls-in-a-wikipedia-page>
- <https://en.wikipedia.org/w/api.php?action=query&titles=Title&prop=links&pllimit=max&format=json>
- [YouTube (Amixem) - WIKIPEDIA CHALLENGE ! (ce jeu est génial)](https://www.youtube.com/watch?v=wgKlFNGU174)
- [YouTube (adumb) - I Made a Graph of Wikipedia... This Is What I Found](https://www.youtube.com/watch?v=JheGL6uSF-4)

14
apps/cli/.eslintrc.json Normal file
View File

@ -0,0 +1,14 @@
{
"root": true,
"extends": ["@repo/eslint-config"],
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
}
]
}

31
apps/cli/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "@repo/cli",
"version": "0.0.1",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"bin": {
"wikipedia-game-solver": "./src/index.ts"
},
"scripts": {
"start": "node --import=tsx ./src/index.ts",
"dev": "node --import=tsx --watch --watch-preserve-output ./src/index.ts",
"lint:eslint": "eslint src --max-warnings 0 --report-unused-disable-directives",
"lint:typescript": "tsc --noEmit"
},
"dependencies": {
"@repo/wikipedia-game-solver": "workspace:*",
"@repo/constants": "workspace:*",
"tsx": "catalog:"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/config-typescript": "workspace:*",
"@types/node": "catalog:",
"@total-typescript/ts-reset": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,3 @@
export const add = (a: number, b: number): number => {
return a + b
}

11
apps/cli/src/index.ts Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env -S node --import=tsx
import { add } from "#abc/def/add.js"
import { VERSION } from "@repo/constants"
import { sum } from "@repo/wikipedia-game-solver/wikipedia-api"
console.log("Hello, world!")
console.log(sum(1, 2))
console.log(add(2, 3))
console.log(`v${VERSION}`)

13
apps/cli/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "@repo/config-typescript/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"resolveJsonModule": true,
"lib": ["ESNext"],
"types": ["@total-typescript/ts-reset", "@types/node"],
"noEmit": true
}
}

View File

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["@repo/eslint-config"]
}

View File

@ -0,0 +1,31 @@
import type { StorybookConfig } from "@storybook/nextjs"
const config: StorybookConfig = {
core: {
disableTelemetry: true,
},
docs: {
defaultName: "Documentation",
},
stories: ["../../../packages/**/*.stories.tsx", "../stories/*.mdx"],
addons: [
"@storybook/addon-essentials",
"@storybook/addon-storysource",
"@storybook/addon-a11y",
"@storybook/addon-links",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
"storybook-dark-mode",
],
framework: {
name: "@storybook/nextjs",
options: {},
},
typescript: {
check: false,
reactDocgen: "react-docgen-typescript",
},
staticDirs: ["../../website/public"],
}
export default config

View File

@ -0,0 +1,50 @@
import "@repo/config-tailwind/styles.css"
import { defaultTranslationValues } from "@repo/i18n/config"
import i18nMessagesEnglish from "@repo/i18n/translations/en-US.json"
import { ThemeProvider } from "@repo/ui/Header/SwitchTheme"
import type { Preview } from "@storybook/react"
import { NextIntlClientProvider } from "next-intl"
import React from "react"
const preview: Preview = {
parameters: {
nextjs: {
appDirectory: true,
},
options: {
storySort: {
order: ["Design System", "User Interface", "Feature"],
},
},
backgrounds: { disable: true },
darkMode: {
darkClass: "dark",
lightClass: "light",
classTarget: "html",
stylePreview: true,
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
decorators: [
(Story) => {
return (
<ThemeProvider>
<NextIntlClientProvider
messages={i18nMessagesEnglish}
locale="en"
defaultTranslationValues={defaultTranslationValues}
>
<Story />
</NextIntlClientProvider>
</ThemeProvider>
)
},
],
}
export default preview

View File

@ -0,0 +1,35 @@
import type { TestRunnerConfig } from "@storybook/test-runner"
import { getStoryContext } from "@storybook/test-runner"
import { checkA11y, configureAxe, injectAxe } from "axe-playwright"
/*
* See https://storybook.js.org/docs/writing-tests/test-runner#test-hook-api
* to learn more about the test-runner hooks API.
*/
const config: TestRunnerConfig = {
async preVisit(page) {
await injectAxe(page)
},
async postVisit(page, context) {
const storyContext = await getStoryContext(page, context)
if (storyContext.parameters?.a11y?.disable) {
return
}
await configureAxe(page, {
rules: storyContext.parameters?.a11y?.config?.rules,
})
await checkA11y(page, "#storybook-root", {
verbose: false,
detailedReport: true,
detailedReportOptions: {
html: true,
},
})
},
}
export default config

View File

@ -0,0 +1,7 @@
{
"projectId": "Project:668708614e9ac6d0b97ea5e5",
"buildScriptName": "build",
"storybookBaseDir": "apps/storybook",
"onlyChanged": true,
"zip": true
}

View File

@ -0,0 +1,54 @@
{
"name": "@repo/storybook",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "storybook build",
"dev": "storybook dev --port 6006 --no-open",
"start": "http-server \"storybook-static\" --port 6006 --silent",
"test": "start-server-and-test \"dev\" http://127.0.0.1:6006 \"test:storybook\"",
"test:storybook": "test-storybook",
"test:storybook-coverage": "test-storybook --coverage",
"chromatic": "chromatic"
},
"dependencies": {
"@repo/config-tailwind": "workspace:*",
"@repo/i18n": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/wikipedia-game-solver": "workspace:*",
"next": "catalog:",
"next-intl": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@chromatic-com/storybook": "catalog:",
"@playwright/test": "catalog:",
"@storybook/addon-a11y": "catalog:",
"@storybook/addon-essentials": "catalog:",
"@storybook/addon-interactions": "catalog:",
"@storybook/addon-links": "catalog:",
"@storybook/addon-storysource": "catalog:",
"@storybook/addon-themes": "catalog:",
"@storybook/blocks": "catalog:",
"@storybook/nextjs": "catalog:",
"@storybook/react": "catalog:",
"@storybook/test": "catalog:",
"@storybook/test-runner": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"axe-playwright": "catalog:",
"chromatic": "catalog:",
"eslint": "catalog:",
"http-server": "catalog:",
"start-server-and-test": "catalog:",
"storybook": "catalog:",
"storybook-dark-mode": "catalog:",
"postcss": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
tailwindcss: {},
},
}
export default config

View File

@ -0,0 +1,31 @@
import { Meta, Title, ColorPalette, ColorItem } from "@storybook/blocks"
import tailwindConfig from "@repo/config-tailwind"
<Meta title="Design System/Colors" />
<Title>Colors</Title>
<ColorPalette>
{Object.entries(tailwindConfig.theme.colors).map(
([colorName, colorValue]) => {
const colors = {}
if (typeof colorValue === "string") {
colors[colorName] = colorValue
} else {
colors.light = colorValue.DEFAULT
colors.dark = colorValue.dark
}
return (
<ColorItem
key={colorName}
title={colorName}
colors={colors}
/>
)
}
)}
</ColorPalette>

View File

@ -0,0 +1,9 @@
import sharedConfig from "@repo/config-tailwind"
/** @type {Pick<import('tailwindcss').Config, "presets" | "content">} */
const config = {
content: [".storybook/preview.tsx", "../../packages/**/*.tsx"],
presets: [sharedConfig],
}
export default config

View File

@ -0,0 +1,3 @@
HOSTNAME=0.0.0.0
PORT=5000
NEXT_TELEMETRY_DISABLED=1

View File

@ -0,0 +1,14 @@
{
"root": true,
"extends": ["@repo/eslint-config/nextjs/.eslintrc.json"],
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
}
]
}

36
apps/website/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
FROM node:22.4.1-slim AS node-pnpm
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /usr/src/app
FROM node-pnpm AS builder
RUN pnpm install --global turbo@2.0.9
COPY ./ ./
RUN turbo prune @repo/website --docker
FROM node-pnpm AS installer
ENV IS_STANDALONE=true
COPY .gitignore .gitignore
COPY --from=builder /usr/src/app/out/json/ ./
COPY --from=builder /usr/src/app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY --from=builder /usr/src/app/out/full/ ./
COPY turbo.json turbo.json
RUN pnpm --filter=@repo/website... exec turbo run build
FROM node-pnpm AS runner
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV NEXT_TELEMETRY_DISABLED=1
ENV IS_STANDALONE=true
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 applicationrunner
USER applicationrunner
COPY --from=installer /usr/src/app/apps/website/next.config.js ./
COPY --from=installer /usr/src/app/apps/website/package.json ./
COPY --from=installer --chown=applicationrunner:nodejs /usr/src/app/apps/website/.next/standalone ./
COPY --from=installer --chown=applicationrunner:nodejs /usr/src/app/apps/website/.next/static ./apps/website/.next/static
COPY --from=installer --chown=applicationrunner:nodejs /usr/src/app/apps/website/public ./apps/website/public
CMD ["node", "apps/website/server.js"]

View File

@ -0,0 +1,7 @@
import { notFound } from "next/navigation"
const CatchAllPage: React.FC = () => {
return notFound()
}
export default CatchAllPage

View File

@ -0,0 +1,26 @@
import type { LocaleProps } from "@repo/i18n/config"
import { MainLayout } from "@repo/ui/MainLayout"
import { Button } from "@repo/ui/design/Button"
import { unstable_setRequestLocale } from "next-intl/server"
import { FaRocket } from "react-icons/fa6"
interface HomePageProps extends LocaleProps {}
const AboutPage: React.FC<HomePageProps> = (props) => {
const { params } = props
// Enable static rendering
unstable_setRequestLocale(params.locale)
return (
<MainLayout>
<section className="my-6 flex items-center space-x-6">
<Button leftIcon={<FaRocket size={18} />} href="/">
Home
</Button>
</section>
</MainLayout>
)
}
export default AboutPage

View File

@ -0,0 +1,38 @@
"use client"
import { MainLayout } from "@repo/ui/MainLayout"
import { useTranslations } from "next-intl"
import { useEffect } from "react"
interface ErrorBoundaryPageProps {
error: Error & { digest?: string }
reset: () => void
}
const ErrorBoundaryPage: React.FC<ErrorBoundaryPageProps> = (props) => {
const { error, reset } = props
const t = useTranslations()
useEffect(() => {
console.error(error)
}, [error])
return (
<MainLayout>
<h1 className="text-3xl font-semibold text-red-600">
{t("errors.error")} 500 - {t("errors.server-error")}
</h1>
<p className="mb-4 mt-2">
<button
className="rounded-md bg-blue-600 px-3 py-2 text-white hover:bg-blue-800"
onClick={reset}
>
{t("errors.try-again")}
</button>
</p>
</MainLayout>
)
}
export default ErrorBoundaryPage

View File

@ -0,0 +1,63 @@
import "@repo/config-tailwind/styles.css"
import { VERSION } from "@repo/constants"
import type { Locale, LocaleProps } from "@repo/i18n/config"
import { LOCALES } from "@repo/i18n/config"
import { Footer } from "@repo/ui/Footer"
import { Header } from "@repo/ui/Header"
import { ThemeProvider } from "@repo/ui/Header/SwitchTheme"
import type { Metadata } from "next"
import { NextIntlClientProvider } from "next-intl"
import {
getMessages,
getTranslations,
unstable_setRequestLocale,
} from "next-intl/server"
export const generateMetadata = async ({
params,
}: LocaleProps): Promise<Metadata> => {
const t = await getTranslations({ locale: params.locale })
return {
title: t("meta.title"),
description: t("meta.description"),
}
}
export const generateStaticParams = (): Array<{ locale: Locale }> => {
return LOCALES.map((locale) => {
return {
locale,
}
})
}
interface LocaleLayoutProps extends React.PropsWithChildren {
params: {
locale: Locale
}
}
const LocaleLayout: React.FC<LocaleLayoutProps> = async (props) => {
const { children, params } = props
// Enable static rendering
unstable_setRequestLocale(params.locale)
const messages = await getMessages()
return (
<html lang={params.locale} suppressHydrationWarning>
<body>
<ThemeProvider>
<NextIntlClientProvider messages={messages}>
<Header />
{children}
<Footer version={VERSION} />
</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
)
}
export default LocaleLayout

View File

@ -0,0 +1,12 @@
import { MainLayout } from "@repo/ui/MainLayout"
import { Spinner } from "@repo/ui/design/Spinner"
const Loading: React.FC = () => {
return (
<MainLayout className="items-center justify-center">
<Spinner size={50} />
</MainLayout>
)
}
export default Loading

View File

@ -0,0 +1,26 @@
import { Link } from "@repo/i18n/navigation"
import { MainLayout } from "@repo/ui/MainLayout"
import { useTranslations } from "next-intl"
/**
* Note that `app/[locale]/[...rest]/page.tsx` is necessary for this page to render.
*/
const NotFound: React.FC = () => {
const t = useTranslations()
return (
<MainLayout>
<h1 className="text-3xl font-semibold text-red-600">
{t("errors.error")} 404 - {t("errors.not-found")}
</h1>
<p className="mb-4 mt-2">
{t("errors.page-doesnt-exist")}{" "}
<Link href="/" className="text-blue-800 hover:underline">
{t("errors.return-to-home-page")}
</Link>
</p>
</MainLayout>
)
}
export default NotFound

View File

@ -0,0 +1,68 @@
import type { LocaleProps } from "@repo/i18n/config"
import { Button } from "@repo/ui/design/Button"
import { Link } from "@repo/ui/design/Link"
import { Typography } from "@repo/ui/design/Typography"
import { MainLayout } from "@repo/ui/MainLayout"
import { WikipediaClient } from "@repo/wikipedia-game-solver/WikipediaClient"
import { useTranslations } from "next-intl"
import { unstable_setRequestLocale } from "next-intl/server"
import Image from "next/image"
import { FaRocket } from "react-icons/fa6"
import WikipediaLogo from "#public/images/Wikipedia-Logo.png"
interface HomePageProps extends LocaleProps {}
const HomePage: React.FC<HomePageProps> = (props) => {
const { params } = props
// Enable static rendering
unstable_setRequestLocale(params.locale)
const t = useTranslations()
return (
<MainLayout>
<section className="text-center">
<Typography as="h1" variant="h1">
{t("home.title")}
</Typography>
<Typography as="p" variant="text1" className="mt-3">
{t.rich("home.description", {
wikipedia: (children) => {
return (
<Link href="https://en.wikipedia.org/" target="_blank">
{children}
</Link>
)
},
})}
</Typography>
</section>
<section className="my-6 flex items-center justify-center">
<Image src={WikipediaLogo} alt="Wikipedia" className="w-72" />
</section>
<section className="my-6 flex items-center space-x-6">
<Button leftIcon={<FaRocket size={18} />} href="/about">
About
</Button>
<Button
leftIcon={<FaRocket size={18} />}
variant="outline"
href="/"
target="_blank"
>
Get Started
</Button>
</section>
<WikipediaClient />
</MainLayout>
)
}
export default HomePage

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,7 @@
interface RootLayoutProps extends React.PropsWithChildren {}
const RootLayout = ({ children }: RootLayoutProps): React.ReactNode => {
return children
}
export default RootLayout

View File

@ -0,0 +1,20 @@
"use client"
import Error from "next/error"
/**
* Render the default Next.js 404 page when a route
* is requested that doesn't match the middleware and
* therefore doesn't have a locale associated with it.
*/
const NotFound: React.FC = () => {
return (
<html lang="en">
<body>
<Error statusCode={404} />
</body>
</html>
)
}
export default NotFound

3
apps/website/i18n.ts Normal file
View File

@ -0,0 +1,3 @@
import i18nConfig from "@repo/i18n/i18n"
export default i18nConfig

View File

@ -0,0 +1,23 @@
import { LOCALES, LOCALE_DEFAULT, LOCALE_PREFIX } from "@repo/i18n/config"
import createMiddleware from "next-intl/middleware"
export default createMiddleware({
locales: LOCALES,
defaultLocale: LOCALE_DEFAULT,
localePrefix: LOCALE_PREFIX,
})
export const config = {
matcher: [
// Enable a redirect to a matching locale at the root
"/",
// Set a cookie to remember the previous locale for
// all requests that have a locale prefix
"/(en-US|fr-FR)/:path*",
// Enable redirects that add missing locales
// (e.g. `/pathnames` -> `/en/pathnames`)
"/((?!_next|_vercel|.*\\..*).*)",
],
}

5
apps/website/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -0,0 +1,18 @@
import createNextIntlPlugin from "next-intl/plugin"
const IS_STANDALONE = process.env.IS_STANDALONE === "true"
/** @type {import('next').NextConfig} */
const nextConfig = {
output: IS_STANDALONE ? "standalone" : undefined,
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
}
const withNextIntl = createNextIntlPlugin()
export default withNextIntl(nextConfig)

40
apps/website/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "@repo/website",
"version": "0.0.1",
"private": true,
"type": "module",
"imports": {
"#*": "./*"
},
"scripts": {
"dev": "next dev --port 5000 --turbo",
"build": "next build",
"start": "next start --port 5000",
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives",
"lint:typescript": "tsc --noEmit"
},
"dependencies": {
"@repo/config-tailwind": "workspace:*",
"@repo/constants": "workspace:*",
"@repo/i18n": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/wikipedia-game-solver": "workspace:*",
"next": "catalog:",
"next-intl": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-icons": "catalog:"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/config-typescript": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@total-typescript/ts-reset": "catalog:",
"eslint": "catalog:",
"postcss": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
tailwindcss: {},
},
}
export default config

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View File

@ -0,0 +1,13 @@
import sharedConfig from "@repo/config-tailwind"
/** @type {Pick<import('tailwindcss').Config, "presets" | "content">} */
const config = {
content: [
"./**/*.tsx",
"../../packages/ui/**/*.tsx",
"../../packages/wikipedia-game-solver/**/*.tsx",
],
presets: [sharedConfig],
}
export default config

View File

@ -0,0 +1,25 @@
{
"extends": "@repo/config-typescript/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["@total-typescript/ts-reset", "@repo/i18n/messages.d.ts"],
"incremental": true,
"noEmit": true,
"allowJs": true,
"jsx": "preserve",
"paths": {
"#*": ["./*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".next"]
}

13
compose.yaml Normal file
View File

@ -0,0 +1,13 @@
services:
wikipedia-game-solver:
container_name: "wikipedia-game-solver"
image: "wikipedia-game-solver"
restart: "unless-stopped"
build:
context: "./"
dockerfile: "./apps/website/Dockerfile"
ports:
- "${WEBSITE_PORT-5000}:${WEBSITE_PORT-5000}"
environment:
PORT: ${WEBSITE_PORT-5000}
env_file: "./apps/website/.env"

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "repo",
"version": "0.0.1",
"private": true,
"type": "module",
"packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903",
"engines": {
"node": ">=22.0.0",
"pnpm": ">=9.5.0"
},
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --parallel",
"start": "turbo run start --parallel",
"test": "turbo run test",
"lint:editorconfig": "editorconfig-checker",
"lint:prettier": "prettier . --check",
"lint:eslint": "turbo run lint:eslint",
"lint:typescript": "turbo run lint:typescript",
"release": "semantic-release"
},
"devDependencies": {
"@saithodev/semantic-release-backmerge": "4.0.1",
"@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1",
"editorconfig-checker": "5.1.8",
"prettier": "3.3.3",
"prettier-plugin-tailwindcss": "0.6.5",
"replace-in-files-cli": "3.0.0",
"semantic-release": "23.1.1",
"turbo": "2.0.9",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,3 @@
{
"extends": ["conventions"]
}

View File

@ -0,0 +1,57 @@
{
"extends": [
"conventions",
"next/core-web-vitals",
"plugin:tailwindcss/recommended",
"plugin:storybook/recommended"
],
"ignorePatterns": [
"next.config.js",
"tailwind.config.js",
"postcss.config.js",
"vitest.config.ts"
],
"settings": {
"tailwindcss": {
"callees": ["classNames", "cva"]
},
"react": {
"version": "detect"
}
},
"rules": {
"tailwindcss/classnames-order": "off",
"tailwindcss/no-custom-classname": "off",
"@next/next/no-html-link-for-pages": "off",
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"react/void-dom-elements-no-children": "error",
"react/jsx-boolean-value": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "next/link",
"message": "Please import from `@repo/i18n/navigation` instead."
},
{
"name": "next/navigation",
"importNames": [
"redirect",
"permanentRedirect",
"useRouter",
"usePathname"
],
"message": "Please import from `@repo/i18n/navigation` instead."
}
]
}
]
}
}

View File

@ -0,0 +1,22 @@
{
"name": "@repo/eslint-config",
"version": "0.0.1",
"private": true,
"main": ".eslintrc.json",
"files": [
".eslintrc.json",
"nextjs/.eslintrc.json"
],
"devDependencies": {
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"eslint": "catalog:",
"eslint-config-conventions": "catalog:",
"eslint-plugin-promise": "catalog:",
"eslint-plugin-unicorn": "catalog:",
"eslint-config-next": "catalog:",
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-tailwindcss": "catalog:",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,14 @@
{
"root": true,
"extends": ["@repo/eslint-config"],
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
}
]
}

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export const classNames = (...inputs: ClassValue[]): string => {
return twMerge(clsx(inputs))
}

3
packages/config-tailwind/index.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import type { Config } from "tailwindcss"
export default Config

View File

@ -0,0 +1,30 @@
{
"name": "@repo/config-tailwind",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./tailwind.config.js",
"types": "./index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./tailwind.config.js",
"require": "./tailwind.config.js",
"default": "./tailwind.config.js"
},
"./classNames": "./classNames.ts",
"./styles.css": "./styles.css"
},
"dependencies": {
"@fontsource/montserrat": "catalog:",
"clsx": "catalog:",
"tailwind-merge": "catalog:"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/config-typescript": "workspace:*",
"eslint": "catalog:",
"postcss": "catalog:",
"tailwindcss": "catalog:"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
tailwindcss: {},
},
}
export default config

View File

@ -0,0 +1,73 @@
@import "@fontsource/montserrat/400.css";
@import "@fontsource/montserrat/500.css";
@import "@fontsource/montserrat/600.css";
@import "@fontsource/montserrat/700.css";
@import "@fontsource/montserrat/800.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
b,
strong {
@apply font-semibold;
}
i,
em {
@apply italic;
}
u {
@apply underline;
}
s {
@apply line-through;
}
abbr[title] {
@apply underline decoration-dotted underline-offset-2;
}
q,
blockquote {
@apply italic tracking-wider;
}
blockquote {
@apply border-gray border-l-4 pl-3 italic;
}
kbd {
@apply bg-gray rounded-md px-2 dark:text-black;
}
mark {
@apply bg-yellow rounded-md px-2;
}
ol {
@apply list-inside list-decimal;
}
ul {
@apply list-inside list-disc;
}
dfn {
@apply font-semibold italic;
cursor: help;
}
}
body {
@apply bg-background dark:bg-background-dark font-sans text-black dark:text-white;
}
@keyframes ripple {
to {
opacity: 0;
transform: scale(2);
}
}

View File

@ -0,0 +1,34 @@
import { fontFamily } from "tailwindcss/defaultTheme"
/** @type {Omit<import('tailwindcss').Config, "content">} */
const config = {
darkMode: "class",
theme: {
colors: {
primary: {
DEFAULT: "#0056b3",
dark: "#00aeff",
},
background: {
DEFAULT: "#fff",
dark: "#181818",
},
white: "#fff",
black: "#000",
gray: "#d1d5db",
"gray-darker": {
DEFAULT: "#4b5563",
dark: "#9ca3af",
},
yellow: "#fef08a",
transparent: "transparent",
inherit: "inherit",
},
fontFamily: {
sans: ["'Montserrat'", ...fontFamily.sans],
},
},
plugins: [],
}
export default config

View File

@ -0,0 +1,10 @@
{
"extends": "@repo/config-typescript/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true
}
}

View File

@ -0,0 +1,8 @@
{
"name": "@repo/config-typescript",
"version": "0.0.1",
"private": true,
"files": [
"tsconfig.json"
]
}

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}

View File

@ -0,0 +1,17 @@
{
"name": "@repo/constants",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./version.ts"
},
"scripts": {
"lint:typescript": "tsc --noEmit"
},
"devDependencies": {
"@repo/config-typescript": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,10 @@
{
"extends": "@repo/config-typescript/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"noEmit": true
}
}

View File

@ -0,0 +1,6 @@
import packageJSON from "./package.json"
export const VERSION =
process.env["NODE_ENV"] === "development"
? "0.0.0-development"
: packageJSON.version

View File

@ -0,0 +1,14 @@
{
"root": true,
"extends": ["@repo/eslint-config"],
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
}
]
}

View File

@ -0,0 +1,33 @@
{
"name": "@repo/i18n",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./translations/*.json": "./src/translations/*.json",
"./config": "./src/config.tsx",
"./i18n": "./src/i18n.ts",
"./messages.d.ts": "./src/messages.d.ts",
"./navigation": "./src/navigation.ts"
},
"scripts": {
"lint:eslint": "eslint src --max-warnings 0 --report-unused-disable-directives",
"lint:typescript": "tsc --noEmit"
},
"dependencies": {
"deepmerge": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/config-typescript": "workspace:*",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@total-typescript/ts-reset": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,30 @@
import type { RichTranslationValues } from "next-intl"
export const LOCALES = ["en-US", "fr-FR"] as const
export type Locale = (typeof LOCALES)[number]
export const LOCALE_DEFAULT = "en-US" satisfies Locale
export const LOCALE_PREFIX = "never"
export interface LocaleProps {
params: {
locale: Locale
}
}
export const defaultTranslationValues: RichTranslationValues = {
br: () => {
return <br />
},
strong: (children) => {
return <strong>{children}</strong>
},
em: (children) => {
return <em>{children}</em>
},
s: (children) => {
return <s>{children}</s>
},
u: (children) => {
return <u>{children}</u>
},
}

27
packages/i18n/src/i18n.ts Normal file
View File

@ -0,0 +1,27 @@
import deepmerge from "deepmerge"
import type { AbstractIntlMessages } from "next-intl"
import { getRequestConfig } from "next-intl/server"
import { notFound } from "next/navigation"
import type { Locale } from "./config"
import { defaultTranslationValues, LOCALE_DEFAULT, LOCALES } from "./config"
export default getRequestConfig(async ({ locale }) => {
if (!LOCALES.includes(locale as Locale)) {
return notFound()
}
const userMessages = (await import(`./translations/${locale}.json`)).default
const defaultMessages = (
await import(`./translations/${LOCALE_DEFAULT}.json`)
).default
const messages = deepmerge<AbstractIntlMessages>(
defaultMessages,
userMessages,
)
return {
messages,
defaultTranslationValues,
}
})

10
packages/i18n/src/messages.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import type en from "./translations/en-US.json"
type Messages = typeof en
declare global {
/**
* Use type safe message keys with `next-intl`.
*/
interface IntlMessages extends Messages {}
}

View File

@ -0,0 +1,9 @@
import { createSharedPathnamesNavigation } from "next-intl/navigation"
import { LOCALES, LOCALE_PREFIX } from "./config"
export const { Link, redirect, usePathname, useRouter, permanentRedirect } =
createSharedPathnamesNavigation({
locales: LOCALES,
localePrefix: LOCALE_PREFIX,
})

View File

@ -0,0 +1,23 @@
{
"meta": {
"title": "Wikipedia Game Solver",
"description": "The Wikipedia Game involves players competing to navigate from one Wikipedia page to another using only internal links.",
"all-rights-reserved": "All rights reserved"
},
"locales": {
"en-US": "🇺🇸 English",
"fr-FR": "🇫🇷 French"
},
"errors": {
"error": "Error",
"page-doesnt-exist": "This page doesn't exist!",
"not-found": "Not Found",
"server-error": "Internal Server Error!",
"return-to-home-page": "Return to the home page?",
"try-again": "Try again ?"
},
"home": {
"title": "Wikipedia Game Solver",
"description": "The Wikipedia Game involves players competing to navigate from one <wikipedia>Wikipedia</wikipedia> page to another using only internal links."
}
}

View File

@ -0,0 +1,23 @@
{
"meta": {
"title": "Wikipedia Game Solver",
"description": "Le jeu Wikipédia implique des joueurs en compétition pour naviguer d'une page Wikipédia à une autre en utilisant uniquement des liens internes.",
"all-rights-reserved": "Tous droits réservés"
},
"locales": {
"en-US": "🇺🇸 Anglais",
"fr-FR": "🇫🇷 Français"
},
"errors": {
"error": "Erreur",
"page-doesnt-exist": "Cette page n'existe pas!",
"not-found": "Introuvable",
"server-error": "Erreur interne du serveur !",
"return-to-home-page": "Retour à la page d'accueil ?",
"try-again": "Réessayer ?"
},
"home": {
"title": "Wikipedia Game Solver",
"description": "Le jeu Wikipédia implique des joueurs en compétition pour naviguer d'une page Wikipédia à une autre en utilisant uniquement des liens internes."
}
}

View File

@ -0,0 +1,14 @@
{
"extends": "@repo/config-typescript/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["@total-typescript/ts-reset"],
"jsx": "preserve",
"noEmit": true
}
}

View File

@ -0,0 +1,14 @@
{
"root": true,
"extends": ["@repo/eslint-config/nextjs/.eslintrc.json"],
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
}
]
}

View File

@ -0,0 +1,26 @@
{
"name": "@repo/react-hooks",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./useIsMounted": "./src/useIsMounted.ts"
},
"scripts": {
"lint:eslint": "eslint src --max-warnings 0 --report-unused-disable-directives",
"lint:typescript": "tsc --noEmit"
},
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/config-typescript": "workspace:*",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@total-typescript/ts-reset": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,15 @@
import { useEffect, useState } from "react"
export interface UseIsMountedOutput {
isMounted: boolean
}
export const useIsMounted = (): UseIsMountedOutput => {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
return { isMounted }
}

View File

@ -0,0 +1,14 @@
{
"extends": "@repo/config-typescript/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["@total-typescript/ts-reset"],
"jsx": "preserve",
"noEmit": true
}
}

View File

@ -0,0 +1,14 @@
{
"root": true,
"extends": ["@repo/eslint-config/nextjs/.eslintrc.json"],
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
}
]
}

46
packages/ui/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "@repo/ui",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./design/Button": "./src/design/Button/Button.tsx",
"./design/Link": "./src/design/Link/Link.tsx",
"./design/Spinner": "./src/design/Spinner/Spinner.tsx",
"./design/Typography": "./src/design/Typography/Typography.tsx",
"./Footer": "./src/Footer/Footer.tsx",
"./Header": "./src/Header/Header.tsx",
"./Header/SwitchTheme": "./src/Header/SwitchTheme.tsx",
"./MainLayout": "./src/MainLayout/MainLayout.tsx"
},
"scripts": {
"lint:eslint": "eslint src --max-warnings 0 --report-unused-disable-directives",
"lint:typescript": "tsc --noEmit"
},
"dependencies": {
"@repo/config-tailwind": "workspace:*",
"@repo/i18n": "workspace:*",
"@repo/react-hooks": "workspace:*",
"cva": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"next-themes": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-icons": "catalog:"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/config-typescript": "workspace:*",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@total-typescript/ts-reset": "catalog:",
"@storybook/blocks": "catalog:",
"@storybook/react": "catalog:",
"@storybook/test": "catalog:",
"eslint": "catalog:",
"postcss": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
tailwindcss: {},
},
}
export default config

View File

@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react"
import { Footer as FooterComponent } from "./Footer"
const meta = {
title: "User Interface/Footer",
component: FooterComponent,
} satisfies Meta<typeof FooterComponent>
export default meta
type Story = StoryObj<typeof meta>
export const Footer: Story = {
args: {
version: "1.0.0",
},
}

View File

@ -0,0 +1,31 @@
import { useTranslations } from "next-intl"
import { Link } from "../design/Link/Link"
export interface FooterProps {
version: string
}
export const Footer: React.FC<FooterProps> = (props) => {
const { version } = props
const t = useTranslations()
return (
<footer className="bg-background dark:bg-background-dark border-gray-darker dark:border-gray-darker-dark flex h-20 flex-col items-center justify-center border-t-2 px-6 py-2 text-lg">
<p>
<Link href="https://theoludwig.fr" target="_blank" isExternal={false}>
Théo LUDWIG
</Link>{" "}
| {t("meta.all-rights-reserved")}
</p>
<p>
Version{" "}
<strong className="text-primary dark:text-primary-dark hover:underline">
{version}
</strong>
</p>
</footer>
)
}

View File

@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react"
import { Header as HeaderComponent } from "./Header"
const meta = {
title: "User Interface/Header",
component: HeaderComponent,
} satisfies Meta<typeof HeaderComponent>
export default meta
type Story = StoryObj<typeof meta>
export const Header: Story = {
args: {},
}

View File

@ -0,0 +1,15 @@
"use client"
import { Locales } from "./Locales"
import { SwitchTheme } from "./SwitchTheme"
export const Header: React.FC = () => {
return (
<header className="bg-background dark:bg-background-dark border-gray-darker dark:border-gray-darker-dark sticky top-0 z-50 flex h-16 flex-col items-center justify-center gap-4 border-b-2 px-4 py-2">
<div className="flex w-full items-center justify-between">
<Locales />
<SwitchTheme />
</div>
</header>
)
}

View File

@ -0,0 +1,42 @@
"use client"
import { classNames } from "@repo/config-tailwind/classNames"
import { LOCALES } from "@repo/i18n/config"
import { usePathname, useRouter } from "@repo/i18n/navigation"
import { useLocale, useTranslations } from "next-intl"
export const Locales: React.FC = () => {
const router = useRouter()
const pathname = usePathname()
const currentLocale = useLocale()
const t = useTranslations()
return (
<section>
<ul className="flex list-none gap-6">
{LOCALES.map((locale) => {
return (
<li
key={locale}
className={classNames("rounded-md p-2", {
"border-primary dark:border-primary-dark border":
locale === currentLocale,
})}
>
<button
className="text-primary dark:text-primary-dark font-semibold hover:underline focus:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
onClick={() => {
router.replace(pathname, { locale, scroll: false })
router.refresh()
}}
>
{t(`locales.${locale}`)}
</button>
</li>
)
})}
</ul>
</section>
)
}

View File

@ -0,0 +1,86 @@
"use client"
import { classNames } from "@repo/config-tailwind/classNames"
import { useIsMounted } from "@repo/react-hooks/useIsMounted"
import { ThemeProvider as NextThemeProvider, useTheme } from "next-themes"
export const THEMES = ["light", "dark"] as const
export type Theme = (typeof THEMES)[number]
export const THEME_DEFAULT = "light" as Theme
export const ThemeProvider: React.FC<React.PropsWithChildren> = (props) => {
const { children } = props
return (
<NextThemeProvider
attribute="class"
defaultTheme={THEME_DEFAULT}
enableSystem={false}
>
{children}
</NextThemeProvider>
)
}
export const SwitchTheme: React.FC = () => {
const { setTheme, theme: themeData } = useTheme()
const { isMounted } = useIsMounted()
const theme = isMounted ? (themeData as Theme) : THEME_DEFAULT
const handleClick = (): void => {
const newTheme = theme === "dark" ? "light" : "dark"
setTheme(newTheme)
}
return (
<div className="flex items-center justify-center" onClick={handleClick}>
<div className="relative inline-block cursor-pointer touch-pan-x select-none border-0 bg-transparent p-0">
<div className="h-[24px] w-[50px] rounded-[30px] bg-[#4d4d4d] p-0 text-white transition-all duration-200 ease-in-out">
<div
className={classNames(
"absolute inset-y-0 left-[8px] my-auto h-[10px] w-[14px] leading-[0] transition-opacity duration-[250ms] ease-in-out",
{
"opacity-100": theme === "dark",
"opacity-0": theme === "light",
},
)}
>
<span className="relative flex size-[10px] items-center justify-center">
🌜
</span>
</div>
<div
className={classNames(
"absolute inset-y-0 right-[10px] my-auto size-[10px] leading-[0]",
{
"opacity-100": theme === "light",
"opacity-0": theme === "dark",
},
)}
>
<span className="relative flex size-[10px] items-center justify-center">
🌞
</span>
</div>
</div>
<div
className={classNames(
"absolute top-px box-border size-[22px] rounded-[50%] bg-[#fafafa] text-white transition-all duration-[250ms] ease-in-out",
{
"left-[27px]": theme === "dark",
"left-0": theme === "light",
},
)}
style={{ border: "1px solid #4d4d4d" }}
/>
<input
type="checkbox"
aria-label="Dark mode toggle"
className="absolute -m-px hidden size-px overflow-hidden border-0 p-0"
defaultChecked
/>
</div>
</div>
)
}

View File

@ -0,0 +1,20 @@
import { classNames } from "@repo/config-tailwind/classNames"
export interface MainLayoutProps extends React.PropsWithChildren {
className?: string
}
export const MainLayout: React.FC<MainLayoutProps> = (props) => {
const { children, className } = props
return (
<main
className={classNames(
"flex min-h-[calc(100vh-144px)] flex-col p-6",
className,
)}
>
{children}
</main>
)
}

View File

@ -0,0 +1,148 @@
import type { Meta, StoryObj } from "@storybook/react"
import { expect, fn, userEvent, within } from "@storybook/test"
import { FaCheck } from "react-icons/fa6"
import type { ButtonLinkProps } from "./Button"
import { Button } from "./Button"
const meta = {
title: "Design System/Button",
component: Button,
tags: ["autodocs"],
args: { onClick: fn() },
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
const ButtonContainer: React.FC<React.PropsWithChildren> = (props) => {
const { children } = props
return <div className="flex gap-4">{children}</div>
}
export const Component: Story = {
args: {
children: "Button",
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByText("Button"))
await expect(args.onClick).toHaveBeenCalled()
},
}
export const Variants: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button variant="solid" {...args}>
Solid
</Button>
<Button variant="outline" {...args}>
Outline
</Button>
</ButtonContainer>
)
},
}
export const Sizes: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button size="small" {...args}>
Small
</Button>
<Button size="medium" {...args}>
Medium
</Button>
<Button size="large" {...args}>
Large
</Button>
</ButtonContainer>
)
},
}
export const Disabled: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button variant="solid" disabled {...args}>
Solid
</Button>
<Button variant="outline" disabled {...args}>
Outline
</Button>
</ButtonContainer>
)
},
}
export const Loading: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button variant="solid" isLoading {...args}>
Solid
</Button>
<Button variant="outline" isLoading {...args}>
Outline
</Button>
</ButtonContainer>
)
},
}
export const Icons: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button leftIcon={<FaCheck size={18} />} {...args}>
Left Icon
</Button>
<Button rightIcon={<FaCheck size={18} />} {...args}>
Right Icon
</Button>
</ButtonContainer>
)
},
}
export const Link: Story = {
args: {
children: "Link",
href: "/",
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await expect(
canvas.getByRole("link", {
name: "Link",
}),
).toHaveAttribute("href", args.href)
},
}
export const LinkWithIcons: Story = {
args: {
href: "/",
},
render: (args) => {
return (
<ButtonContainer>
<Button leftIcon={<FaCheck size={18} />} {...(args as ButtonLinkProps)}>
Link Left Icon
</Button>
<Button
rightIcon={<FaCheck size={18} />}
{...(args as ButtonLinkProps)}
>
Link Right Icon
</Button>
</ButtonContainer>
)
},
}

View File

@ -0,0 +1,111 @@
import { classNames } from "@repo/config-tailwind/classNames"
import { Link as NextLink } from "@repo/i18n/navigation"
import type { VariantProps } from "cva"
import { cva } from "cva"
import { Spinner } from "../Spinner/Spinner"
import { Ripple } from "./Ripple"
const buttonVariants = cva({
base: "relative inline-flex items-center justify-center overflow-hidden rounded-md text-base font-semibold transition duration-150 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
variants: {
variant: {
solid: "bg-primary hover:bg-primary/80 text-white",
outline:
"dark:border-primary-dark/60 dark:text-primary-dark dark:hover:border-primary-dark border-primary/60 text-primary hover:border-primary hover:bg-gray border bg-transparent dark:hover:bg-transparent",
},
size: {
small: "h-9 rounded-md px-3",
medium: "h-10 px-4 py-2",
large: "h-11 rounded-md px-8",
},
},
defaultVariants: {
variant: "solid",
size: "medium",
},
})
interface ButtonBaseProps extends VariantProps<typeof buttonVariants> {
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
disabled?: boolean
isLoading?: boolean
}
interface ButtonElementProps extends React.ComponentPropsWithoutRef<"button"> {}
interface LinkElementProps
extends React.ComponentPropsWithoutRef<typeof NextLink> {}
export type ButtonLinkProps = ButtonBaseProps &
LinkElementProps & { href: string }
export type ButtonButtonProps = ButtonBaseProps &
ButtonElementProps & { href?: never }
export type ButtonProps = ButtonButtonProps | ButtonLinkProps
/**
* Buttons allow users to take actions, and make choices, with a single click.
* @param props
* @returns
*/
export const Button: React.FC<ButtonProps> = (props) => {
const rippleColor =
props.variant === "outline" ? "rgb(30, 64, 175)" : "rgb(229, 231, 235)"
if (typeof props.href === "string") {
const { variant, size, leftIcon, rightIcon, className, children, ...rest } =
props
return (
<NextLink
className={classNames(buttonVariants({ variant, size }), className)}
{...rest}
>
{leftIcon != null ? <span className="mr-2">{leftIcon}</span> : null}
<span>{children}</span>
{rightIcon != null ? <span className="ml-2">{rightIcon}</span> : null}
<Ripple color={rippleColor} />
</NextLink>
)
}
const {
variant,
size,
leftIcon,
rightIcon,
className,
isLoading = false,
disabled = false,
children,
...rest
} = props
const isDisabled = disabled || isLoading
const leftIconElement = isLoading ? (
<Spinner size={18} className="text-inherit dark:text-inherit" />
) : (
leftIcon
)
return (
<button
className={classNames(buttonVariants({ variant, size }), className)}
disabled={isDisabled}
{...rest}
>
{leftIconElement != null ? (
<span className="mr-2">{leftIconElement}</span>
) : null}
<span>{children}</span>
{rightIcon != null && !isLoading ? (
<span className="ml-2">{rightIcon}</span>
) : null}
<Ripple color={rippleColor} />
</button>
)
}

View File

@ -0,0 +1,91 @@
"use client"
import { useLayoutEffect, useState } from "react"
const useDebouncedRippleCleanUp = (
rippleCount: number,
duration: number,
cleanUpFunction: () => void,
): void => {
useLayoutEffect(() => {
let bounce: ReturnType<typeof setTimeout> | undefined
if (rippleCount > 0) {
clearTimeout(bounce)
bounce = setTimeout(() => {
cleanUpFunction()
clearTimeout(bounce)
}, duration * 4)
}
return () => {
return clearTimeout(bounce)
}
}, [rippleCount, duration, cleanUpFunction])
}
export interface RippleProps {
/**
* The color of the ripple effect.
*/
color?: string
/**
* The duration of the ripple animation in milliseconds.
*/
duration?: number
}
interface RippleItem {
x: number
y: number
size: number
}
export const Ripple: React.FC<RippleProps> = (props) => {
const { duration = 1_200, color = "rgb(229, 231, 235)" } = props
const [rippleArray, setRippleArray] = useState<RippleItem[]>([])
useDebouncedRippleCleanUp(rippleArray.length, duration, () => {
setRippleArray([])
})
const addRipple: React.MouseEventHandler<HTMLDivElement> = (event) => {
const rippleContainer = event.currentTarget.getBoundingClientRect()
const size =
rippleContainer.width > rippleContainer.height
? rippleContainer.width
: rippleContainer.height
const x = event.pageX - rippleContainer.x - size / 2
const y = event.pageY - rippleContainer.y - size / 2
const newRipple: RippleItem = {
x,
y,
size,
}
setRippleArray([...rippleArray, newRipple])
}
return (
<div className="absolute inset-0" onMouseDown={addRipple}>
{rippleArray.map((ripple, index) => {
return (
<span
key={"span" + index}
className="absolute rounded-full opacity-75"
style={{
transform: "scale(0)",
backgroundColor: color,
animationName: "ripple",
animationDuration: `${duration}ms`,
top: ripple.y,
left: ripple.x,
width: ripple.size,
height: ripple.size,
}}
/>
)
})}
</div>
)
}

View File

@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react"
import { Link } from "./Link"
const meta = {
title: "Design System/Link",
component: Link,
tags: ["autodocs"],
} satisfies Meta<typeof Link>
export default meta
type Story = StoryObj<typeof meta>
export const Component: Story = {
args: {
children: "Link",
href: "/",
},
}
export const External: Story = {
args: {
children: "Link",
href: "/",
target: "_blank",
isExternal: true,
},
}

View File

@ -0,0 +1,35 @@
import { classNames } from "@repo/config-tailwind/classNames"
import { Link as NextLink } from "@repo/i18n/navigation"
import { FiExternalLink } from "react-icons/fi"
export interface LinkProps extends React.ComponentProps<typeof NextLink> {
isExternal?: boolean
}
/**
* Link is an actionable text component with connection to another web pages.
* @param props
* @returns
*/
export const Link: React.FC<LinkProps> = (props) => {
const { className, children, target, isExternal = true, ...rest } = props
return (
<NextLink
className={classNames(
"text-primary dark:text-primary-dark inline-flex items-center gap-1 font-semibold hover:underline focus:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className,
)}
target={target}
{...rest}
>
{children}
{target === "_blank" && isExternal ? (
<FiExternalLink size={16} strokeWidth={2.5} />
) : (
<></>
)}
</NextLink>
)
}

Some files were not shown because too many files have changed in this diff Show More