From 919f4c2585843af3b02da3841cd7627dcf70b56f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 3 Dec 2024 13:47:13 +0100 Subject: [PATCH] everything --- .env.example | 2 + .github/PULL_REQUEST_TEMPLATE.md | 42 +++ .github/workflows/validate.yml | 54 ++++ .gitignore | 94 +++++++ .npmrc | 6 + .vscode/extensions.json | 3 + .vscode/settings.json | 43 ++++ LICENSE | 21 ++ app/components/game/Card.tsx | 45 ++++ app/components/game/Grid.tsx | 37 +++ app/components/game/Leaderboard.tsx | 32 +++ app/components/game/PlayerCursors.tsx | 23 ++ app/components/game/VictoryScreen.tsx | 32 +++ app/db.server.ts | 5 + app/entry.client.tsx | 53 ++++ app/entry.server.tsx | 72 ++++++ app/env.server.ts | 64 +++++ app/library/README.md | 30 +++ app/library/icon/Icon.tsx | 46 ++++ app/library/icon/README.md | 11 + app/library/icon/icons/icon.svg | 6 + app/library/icon/icons/types.ts | 7 + .../language-switcher/LanguageSwitcher.tsx | 26 ++ app/library/language-switcher/README.md | 26 ++ app/library/language-switcher/index.ts | 1 + app/localization/README.md | 19 ++ app/localization/i18n.server.ts | 20 ++ app/localization/i18n.ts | 11 + app/localization/resource.ts | 19 ++ app/queries/user.server.ts | 14 + app/root.tsx | 131 ++++++++++ app/routes.ts | 3 + app/routes/_index.tsx | 168 ++++++++++++ app/routes/resource.locales.ts | 40 +++ app/routes/robots[.]txt.ts | 22 ++ app/routes/rooms.$roomId.tsx | 241 ++++++++++++++++++ app/routes/rooms.join.ts | 67 +++++ app/routes/sitemap-index[.]xml.ts | 23 ++ app/routes/sitemap.$lang[.]xml.ts | 27 ++ app/server/context.ts | 34 +++ app/server/index.ts | 12 + app/session.server.ts | 20 ++ app/tailwind.css | 16 ++ app/types/supabase.ts | 66 +++++ app/utils/README.md | 7 + app/utils/css.test.ts | 39 +++ app/utils/css.ts | 4 + app/utils/emoji.ts | 80 ++++++ app/utils/game.ts | 17 ++ app/utils/http.ts | 23 ++ app/utils/supabase.ts | 9 + .../20241129133157_init_project/migration.sql | 28 ++ .../migration.sql | 46 ++++ .../migration.sql | 4 + .../migration.sql | 2 + .../migration.sql | 28 ++ .../migration.sql | 32 +++ .../migration.sql | 9 + .../20241130184026_fix/migration.sql | 14 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 57 +++++ public/banner.png | Bin 0 -> 327568 bytes public/base-stack.png | Bin 0 -> 289655 bytes public/favicon.ico | Bin 0 -> 16958 bytes public/logo.png | Bin 0 -> 4534 bytes resources/icons/shopping-cart.svg | 1 + resources/locales/bs/common.json | 3 + resources/locales/en/common.json | 3 + scripts/README.md | 144 +++++++++++ scripts/cleanup.ts | 76 ++++++ scripts/setup.ts | 97 +++++++ tests/setup.unit.ts | 1 + 72 files changed, 2461 insertions(+) create mode 100644 .env.example create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 app/components/game/Card.tsx create mode 100644 app/components/game/Grid.tsx create mode 100644 app/components/game/Leaderboard.tsx create mode 100644 app/components/game/PlayerCursors.tsx create mode 100644 app/components/game/VictoryScreen.tsx create mode 100644 app/db.server.ts create mode 100644 app/entry.client.tsx create mode 100644 app/entry.server.tsx create mode 100644 app/env.server.ts create mode 100644 app/library/README.md create mode 100644 app/library/icon/Icon.tsx create mode 100644 app/library/icon/README.md create mode 100644 app/library/icon/icons/icon.svg create mode 100644 app/library/icon/icons/types.ts create mode 100644 app/library/language-switcher/LanguageSwitcher.tsx create mode 100644 app/library/language-switcher/README.md create mode 100644 app/library/language-switcher/index.ts create mode 100644 app/localization/README.md create mode 100644 app/localization/i18n.server.ts create mode 100644 app/localization/i18n.ts create mode 100644 app/localization/resource.ts create mode 100644 app/queries/user.server.ts create mode 100644 app/root.tsx create mode 100644 app/routes.ts create mode 100644 app/routes/_index.tsx create mode 100644 app/routes/resource.locales.ts create mode 100644 app/routes/robots[.]txt.ts create mode 100644 app/routes/rooms.$roomId.tsx create mode 100644 app/routes/rooms.join.ts create mode 100644 app/routes/sitemap-index[.]xml.ts create mode 100644 app/routes/sitemap.$lang[.]xml.ts create mode 100644 app/server/context.ts create mode 100644 app/server/index.ts create mode 100644 app/session.server.ts create mode 100644 app/tailwind.css create mode 100644 app/types/supabase.ts create mode 100644 app/utils/README.md create mode 100644 app/utils/css.test.ts create mode 100644 app/utils/css.ts create mode 100644 app/utils/emoji.ts create mode 100644 app/utils/game.ts create mode 100644 app/utils/http.ts create mode 100644 app/utils/supabase.ts create mode 100644 prisma/migrations/20241129133157_init_project/migration.sql create mode 100644 prisma/migrations/20241130174702_change_the_tables/migration.sql create mode 100644 prisma/migrations/20241130175537_extend_room_info/migration.sql create mode 100644 prisma/migrations/20241130175748_added_names_to_rooms/migration.sql create mode 100644 prisma/migrations/20241130181745_add_missing_relations/migration.sql create mode 100644 prisma/migrations/20241130182328_fix_relations/migration.sql create mode 100644 prisma/migrations/20241130183814_change_flipped_indices/migration.sql create mode 100644 prisma/migrations/20241130184026_fix/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 public/banner.png create mode 100644 public/base-stack.png create mode 100644 public/favicon.ico create mode 100644 public/logo.png create mode 100644 resources/icons/shopping-cart.svg create mode 100644 resources/locales/bs/common.json create mode 100644 resources/locales/en/common.json create mode 100644 scripts/README.md create mode 100644 scripts/cleanup.ts create mode 100644 scripts/setup.ts create mode 100644 tests/setup.unit.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7764bac --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Add your env variables here +APP_DEPLOYMENT_ENV="staging" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ef94499 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,42 @@ +Fixes # + +# Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. +List any dependencies that are required for this change. + +## Type of change + +Please mark relevant options with an `x` in the brackets. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update +- [ ] Algorithm update - updates algorithm documentation/questions/answers etc. +- [ ] Other (please describe): + +# How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also +list any relevant details for your test configuration + +- [ ] Integration tests +- [ ] Unit tests +- [ ] Manual tests +- [ ] No tests required + +# Reviewer checklist + +Mark everything that needs to be checked before merging the PR. + +- [ ] Check if the UI is working as expected and is satisfactory +- [ ] Check if the code is well documented +- [ ] Check if the behavior is what is expected +- [ ] Check if the code is well tested +- [ ] Check if the code is readable and well formatted +- [ ] Additional checks (document below if any) + +# Screenshots (if appropriate): + +# Questions (if appropriate): diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..209b743 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,54 @@ +name: 🚀 Validation Pipeline +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +on: + push: + branches: [main] + pull_request: + branches: [main] +permissions: + actions: write + contents: read + # Required to put a comment into the pull-request + pull-requests: write +jobs: + lint: + name: ⬣ Biome lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: biomejs/setup-biome@v2 + - run: biome ci . --reporter=github + + typecheck: + needs: lint + name: 🔎 Type check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: "package.json" + cache: "pnpm" + - run: pnpm install --prefer-offline --frozen-lockfile + - run: pnpm run typecheck + + vitest: + needs: typecheck + name: ⚡ Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: "package.json" + cache: "pnpm" + - run: pnpm install --prefer-offline --frozen-lockfile + - run: pnpm run test:cov + - name: "Report Coverage" + # Only works if you set `reportOnFailure: true` in your vite config as specified above + if: always() + uses: davelosert/vitest-coverage-report-action@v2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb8a1df --- /dev/null +++ b/.gitignore @@ -0,0 +1,94 @@ +node_modules +public/build +build +dist +out +coverage +.history +.react-router + +# Other Coverage tools +*.lcov + +# macOS +.DS_* + +# Cache Directories and files +.cache +.yarn* +.env* +!.env.example +.swp* +.turbo +.npm +.stylelintcache +*.tsbuildinfo +.node_repl_history + +# Lock files from other package managers +package-lock.json +yarn.lock + +# General tempory files and directories +t?mp +.t?mp +*.t?mp + +# Docusaurus cache and generated files +.docusaurus + +# Output of 'npm pack' +*.tgz +*.tar +*.tar.gz +*.tar.bz2 +*.tbz +*.zip + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +vite.config.ts.* + +# Playwright various test reports +test-results +playwright-report +blob-report + + +# Editors +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + + +# Make it harder to accidentally commit files in the root +/*.json +/*.yaml +/*.yml +/*.toml +/*.ts +/*.tsx +/*.js +/*.jsx +/*.sh + +# Dont commit sqlite database files +*.db +*.sqlite +*.sqlite3 +*.db-journal diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9e299db --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +enable-pre-post-scripts=true +side-effects-cache=false +save-exact=true +audit=false +fund=false +progress=false \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..06e7434 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["codeforge.remix-forge", "biomejs.biome"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..00b93fb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,43 @@ +{ + "editor.formatOnSave": true, + "editor.formatOnType": false, + "editor.renderWhitespace": "all", + "editor.rulers": [120, 160], + "editor.codeActionsOnSave": { + "source.fixAll": "always", + "source.organizeImports": "never", + "source.organizeImports.biome": "always", + "quickfix.biome": "always" + }, + "eslint.enable": false, + "prettier.enable": false, + "editor.insertSpaces": false, + "editor.detectIndentation": false, + "editor.tabSize": 2, + "editor.trimAutoWhitespace": true, + "workbench.colorCustomizations": { + "editorWhitespace.foreground": "#333" + }, + "files.trimTrailingWhitespace": true, + "files.trimTrailingWhitespaceInRegexAndStrings": true, + "files.trimFinalNewlines": true, + "[yaml]": { + "editor.defaultFormatter": "redhat.vscode-yaml" + }, + "biome.enabled": true, + "editor.defaultFormatter": "biomejs.biome", + "[javascript][typescript][typescriptreact][javascriptreact][json][jsonc][vue][astro][svelte][css][graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "explorer.fileNesting.patterns": { + "*.ts": "${basename}.*.${extname}", + ".env": ".env.*", + "*.tsx": "${basename}.*.${extname},${basename}.*.ts", + "package.json": "*.json, *.yml, *.config.js, *.config.ts, *.yaml", + "readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*", + "Readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*", + "README*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*", + "Dockerfile": "*.dockerfile, .devcontainer.*, .dockerignore, captain-definition, compose.*, docker-compose.*, dockerfile*" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a910a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Forge 42 + +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. diff --git a/app/components/game/Card.tsx b/app/components/game/Card.tsx new file mode 100644 index 0000000..b895ad4 --- /dev/null +++ b/app/components/game/Card.tsx @@ -0,0 +1,45 @@ +import { cn } from "~/utils/css" + +interface CardProps { + value: string + isFlipped: boolean + isMatched: boolean + onClick: () => void + disabled?: boolean +} +export function Card({ value, isFlipped, isMatched, onClick, disabled }: CardProps) { + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: +
!disabled && onClick()} + className={cn( + "relative size-20 lg:size-36 cursor-pointer transition duration-500", + disabled ? "cursor-not-allowed opacity-50" : "" + )} + style={{ perspective: "1000px" }} + > + {/* Front face (hidden when flipped) */} +
+ {value} +
+ + {/* Back face (shown when flipped) */} +
+ {value} +
+
+ ) +} diff --git a/app/components/game/Grid.tsx b/app/components/game/Grid.tsx new file mode 100644 index 0000000..b043eab --- /dev/null +++ b/app/components/game/Grid.tsx @@ -0,0 +1,37 @@ +import { Card } from "./Card" + +interface GridProps { + size: 4 | 6 | 8 | 16 | 32 + cards: string[] + flippedIndices: number[] + matchedPairs: string[] + onCardClick: (index: number) => void + isCurrentTurn: boolean +} + +enum ColCount { + col4 = "", + col6 = "lg:grid-cols-6", + col8 = "lg:grid-cols-7", + col16 = "lg:grid-cols-7", + col32 = "lg:grid-cols-7", +} + +export function Grid({ cards, flippedIndices, matchedPairs, onCardClick, isCurrentTurn, size }: GridProps) { + const className = ColCount[`col${size}`] + return ( +
+ {cards.map((value, index) => ( + + key={value + index} + value={value} + isFlipped={flippedIndices.includes(index)} + isMatched={matchedPairs.includes(value)} + onClick={() => onCardClick(index)} + disabled={!isCurrentTurn || flippedIndices.includes(index) || matchedPairs.includes(value)} + /> + ))} +
+ ) +} diff --git a/app/components/game/Leaderboard.tsx b/app/components/game/Leaderboard.tsx new file mode 100644 index 0000000..b9ee4bc --- /dev/null +++ b/app/components/game/Leaderboard.tsx @@ -0,0 +1,32 @@ +import type { Player } from "~/routes/rooms.$roomId" +import { cn } from "~/utils/css" + +export function Leaderboard({ players, currentTurn }: { players: Player[]; currentTurn: string | null }) { + const sortedPlayers = [...players].sort((a, b) => b.score - a.score) + + return ( +
+

Leaderboard

+
+ {sortedPlayers.map((player) => ( +
+
+ {player.player?.name} + {player.player?.name === currentTurn && ( + Current Turn + )} + {!player.isActive && (inactive)} +
+ {player.score} +
+ ))} +
+
+ ) +} diff --git a/app/components/game/PlayerCursors.tsx b/app/components/game/PlayerCursors.tsx new file mode 100644 index 0000000..3aa9cfa --- /dev/null +++ b/app/components/game/PlayerCursors.tsx @@ -0,0 +1,23 @@ +import type { Player } from "~/routes/rooms.$roomId" + +export function PlayerCursors({ players }: { players: Player[] }) { + return ( + <> + {players.map( + (player) => + player.isActive && ( +
+
{player.player?.name}
+
+ ) + )} + + ) +} diff --git a/app/components/game/VictoryScreen.tsx b/app/components/game/VictoryScreen.tsx new file mode 100644 index 0000000..ed90f47 --- /dev/null +++ b/app/components/game/VictoryScreen.tsx @@ -0,0 +1,32 @@ +import { Link } from "react-router" +import type { Player } from "~/routes/rooms.$roomId" + +export function VictoryScreen({ players }: { players: Player[] }) { + const sortedPlayers = [...players].sort((a, b) => b.score - a.score).slice(0, 3) + + const medals = ["🥇", "🥈", "🥉"] + + return ( +
+
+

Game Over!

+
+ {sortedPlayers.map((player, index) => ( +
+
+ {medals[index]} + {player.player?.name} +
+ {player.score} points +
+ ))} +
+ + + +
+
+ ) +} diff --git a/app/db.server.ts b/app/db.server.ts new file mode 100644 index 0000000..42f55d2 --- /dev/null +++ b/app/db.server.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "@prisma/client" + +const db = new PrismaClient() + +export { db } diff --git a/app/entry.client.tsx b/app/entry.client.tsx new file mode 100644 index 0000000..a93f6da --- /dev/null +++ b/app/entry.client.tsx @@ -0,0 +1,53 @@ +import i18next from "i18next" +import LanguageDetector from "i18next-browser-languagedetector" +import Fetch from "i18next-fetch-backend" +import { StrictMode, startTransition } from "react" +import { hydrateRoot } from "react-dom/client" +import { I18nextProvider, initReactI18next } from "react-i18next" +import { HydratedRouter } from "react-router/dom" +import { getInitialNamespaces } from "remix-i18next/client" +import i18n from "~/localization/i18n" + +async function hydrate() { + // eslint-disable-next-line import/no-named-as-default-member + await i18next + .use(initReactI18next) // Tell i18next to use the react-i18next plugin + .use(LanguageDetector) // Setup a client-side language detector + .use(Fetch) // Setup your backend + .init({ + ...i18n, // spread the configuration + // This function detects the namespaces your routes rendered while SSR use + ns: getInitialNamespaces(), + backend: { + loadPath: "/resource/locales?lng={{lng}}&ns={{ns}}", + }, + detection: { + // Here only enable htmlTag detection, we'll detect the language only + // server-side with remix-i18next, by using the `` attribute + // we can communicate to the client the language detected server-side + order: ["htmlTag"], + // Because we only use htmlTag, there's no reason to cache the language + // on the browser, so we disable it + caches: [], + }, + }) + + startTransition(() => { + hydrateRoot( + document, + + + + + + ) + }) +} + +if (window.requestIdleCallback) { + window.requestIdleCallback(hydrate) +} else { + // Safari doesn't support requestIdleCallback + // https://caniuse.com/requestidlecallback + window.setTimeout(hydrate, 1) +} diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 0000000..aa14917 --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,72 @@ +import { PassThrough } from "node:stream" +import { createReadableStreamFromReadable } from "@react-router/node" +import { createInstance } from "i18next" +import { isbot } from "isbot" +import { renderToPipeableStream } from "react-dom/server" +import { I18nextProvider, initReactI18next } from "react-i18next" +import { type AppLoadContext, type EntryContext, ServerRouter } from "react-router" +import i18n from "./localization/i18n" // your i18n configuration file +import i18nextOpts from "./localization/i18n.server" +import { resources } from "./localization/resource" + +const ABORT_DELAY = 5000 + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + context: EntryContext, + appContext: AppLoadContext +) { + const callbackName = isbot(request.headers.get("user-agent")) ? "onAllReady" : "onShellReady" + const instance = createInstance() + const lng = appContext.lang + // biome-ignore lint/suspicious/noExplicitAny: + const ns = i18nextOpts.getRouteNamespaces(context as any) + + await instance + .use(initReactI18next) // Tell our instance to use react-i18next + .init({ + ...i18n, // spread the configuration + lng, // The locale we detected above + ns, // The namespaces the routes about to render wants to use + resources, + }) + + return new Promise((resolve, reject) => { + let didError = false + + const { pipe, abort } = renderToPipeableStream( + + + , + { + [callbackName]: () => { + const body = new PassThrough() + const stream = createReadableStreamFromReadable(body) + responseHeaders.set("Content-Type", "text/html") + + resolve( + // @ts-expect-error - We purposely do not define the body as existent so it's not used inside loaders as it's injected there as well + appContext.body(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }) + ) + + pipe(body) + }, + onShellError(error: unknown) { + reject(error) + }, + onError(error: unknown) { + didError = true + // biome-ignore lint/suspicious/noConsole: We console log the error + console.error(error) + }, + } + ) + + setTimeout(abort, ABORT_DELAY) + }) +} diff --git a/app/env.server.ts b/app/env.server.ts new file mode 100644 index 0000000..2ac406a --- /dev/null +++ b/app/env.server.ts @@ -0,0 +1,64 @@ +import { z } from "zod" + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]), + APP_DEPLOYMENT_ENV: z.enum(["staging", "production"]), + SUPABASE_URL: z.string().url(), + SUPABASE_KEY: z.string(), + DATABASE_URL: z.string(), + DIRECT_URL: z.string(), + SESSION_SECRET: z.string(), +}) + +type APP_ENV = z.infer +let env: APP_ENV +/** + * Helper method used for initializing .env vars in your entry.server.ts file. It uses + * zod to validate your .env and throws if it's not valid. + * @returns Initialized env vars + */ +export const initEnv = () => { + // biome-ignore lint/nursery/noProcessEnv: This should be the only place to use process.env directly + const envData = envSchema.safeParse(process.env) + + if (!envData.success) { + // biome-ignore lint/suspicious/noConsole: We want this to be logged + console.error("❌ Invalid environment variables:", envData.error.flatten().fieldErrors) + throw new Error("Invalid environment variables") + } + + env = envData.data + + // Do not log the message when running tests + if (env.NODE_ENV !== "test") { + // biome-ignore lint/suspicious/noConsole: We want this to be logged + console.log("✅ Environment variables loaded successfully") + } + return envData.data +} + +/** + * Helper method for you to return client facing .env vars, only return vars that are needed on the client. + * Otherwise you would expose your server vars to the client if you returned them from here as this is + * directly sent in the root to the client and set on the window.env + * @returns Subset of the whole process.env to be passed to the client and used there + */ +export const getClientEnv = () => { + const serverEnv = env + return { + NODE_ENV: serverEnv.NODE_ENV, + SUPABASE_URL: serverEnv.SUPABASE_URL, + SUPABASE_KEY: serverEnv.SUPABASE_KEY, + } +} + +type CLIENT_ENV = ReturnType + +declare global { + interface Window { + env: CLIENT_ENV + } + namespace NodeJS { + interface ProcessEnv extends APP_ENV {} + } +} diff --git a/app/library/README.md b/app/library/README.md new file mode 100644 index 0000000..4703bde --- /dev/null +++ b/app/library/README.md @@ -0,0 +1,30 @@ +# Library components + +This directory contains the library components of the application. The library components are the components that are shared across +the application. They are the components that are used in multiple places in the application. Some examples of library components +are buttons, inputs, modals, etc. + +The library components are placed in the `app/library` directory. And you can import them like so: + +```js +import { Button } from '~/library/button' +``` + +As you can see, the library components are imported from the `~/library` directory. This is because the `~/` alias is set to the `app` directory. +Please do not use barrel files to export from the library directory. Instead, import the files directly where you need them. + +For example, do this: + +```js +import { Button } from '~/library/button' +``` + +Instead of this: + +```js +import { Button } from '~/library' +``` + +It can cause circular dependencies in your project and make it harder to understand where things are coming from. + + diff --git a/app/library/icon/Icon.tsx b/app/library/icon/Icon.tsx new file mode 100644 index 0000000..965fe12 --- /dev/null +++ b/app/library/icon/Icon.tsx @@ -0,0 +1,46 @@ +import type { SVGProps } from "react" +import { cn } from "~/utils/css" +import spriteHref from "./icons/icon.svg" +import type { IconName } from "./icons/types" + +export enum IconSize { + xs = "12", + sm = "16", + md = "24", + lg = "32", + xl = "40", +} + +export type IconSizes = keyof typeof IconSize + +export interface IconProps extends SVGProps { + name: IconName + testId?: string + className?: string + size?: IconSizes +} + +/** + * Icon component wrapper for SVG icons. + * @returns SVG icon as a react component + */ +export const Icon = ({ name, testId, className, size = "md", ...props }: IconProps) => { + const iconSize = IconSize[size] + const iconClasses = cn("inline-block flex-shrink-0", className) + return ( + + {name} + + + ) +} +export type { IconName } diff --git a/app/library/icon/README.md b/app/library/icon/README.md new file mode 100644 index 0000000..b90c40f --- /dev/null +++ b/app/library/icon/README.md @@ -0,0 +1,11 @@ +# Icon generation and spritesheets + +This directory is the output directory for the icons. The icons are generated from the `resources/icons` directory. + +The icons are generated using the `vite-plugin-icons-spritesheet` package. + +All the icons are generated as symbols inside of a spritesheet svg element and the `Icon.tsx` +component uses the spritesheet to display the icons. + +The `Icon.tsx` component is a simple component that takes a `name` prop and displays the icon. It is fully +type-safe and highly configurable. \ No newline at end of file diff --git a/app/library/icon/icons/icon.svg b/app/library/icon/icons/icon.svg new file mode 100644 index 0000000..cc28f7b --- /dev/null +++ b/app/library/icon/icons/icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/library/icon/icons/types.ts b/app/library/icon/icons/types.ts new file mode 100644 index 0000000..16b59a6 --- /dev/null +++ b/app/library/icon/icons/types.ts @@ -0,0 +1,7 @@ +// This file is generated by icon spritesheet generator + +export const iconNames = [ + "ShoppingCart", +] as const + +export type IconName = typeof iconNames[number] diff --git a/app/library/language-switcher/LanguageSwitcher.tsx b/app/library/language-switcher/LanguageSwitcher.tsx new file mode 100644 index 0000000..a13393a --- /dev/null +++ b/app/library/language-switcher/LanguageSwitcher.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from "react-i18next" +import { Link, useLocation } from "react-router" +import { supportedLanguages } from "~/localization/resource" + +const LanguageSwitcher = () => { + const { i18n } = useTranslation() + const location = useLocation() + const to = location.pathname + + return ( +
+ {supportedLanguages.map((language) => ( + i18n.changeLanguage(language)} + > + {language} + + ))} +
+ ) +} + +export { LanguageSwitcher } diff --git a/app/library/language-switcher/README.md b/app/library/language-switcher/README.md new file mode 100644 index 0000000..9306fec --- /dev/null +++ b/app/library/language-switcher/README.md @@ -0,0 +1,26 @@ +# Language switcher + +This is a simple language switcher for your website. It uses a simple JavaScript to switch between languages. + +It comes with minimal styling so you can style it however you want. + +## How to use + +1. Import into wherever you want to use the component. +2. Use the `LanguageSwitcher` component. + +```js +import { LanguageSwitcher } from '~/library/language-switcher' + +export default function MyComponent() { + return ( + + ) +} +``` + +## Benefits + +- It changes the url with the current location by pre-pending the language code. +- It uses all available languages from the `i18n` configuration. +- It uses the `useI18n` hook to get the current language and the available languages. \ No newline at end of file diff --git a/app/library/language-switcher/index.ts b/app/library/language-switcher/index.ts new file mode 100644 index 0000000..b2adc3d --- /dev/null +++ b/app/library/language-switcher/index.ts @@ -0,0 +1 @@ +export { LanguageSwitcher } from "./LanguageSwitcher" diff --git a/app/localization/README.md b/app/localization/README.md new file mode 100644 index 0000000..b1bfd97 --- /dev/null +++ b/app/localization/README.md @@ -0,0 +1,19 @@ +# Localization + +Localization works by using the `i18next` package. Everything is configured inside of this folder. +The localization works by using the `/resources/locales` folder. This folder contains all the translations for the different languages. You can add new translations by adding new files to this folder and then changing the `resources.ts` file to include the new language. + +The server part is set up in the `entry.server.tsx` file, and the client part, conversely, is in the `entry.client.tsx` file and also the `root.tsx` file. + +The language is changed by setting the `lng` search parameter in the url. + +## Server-side + +Due to the fact that the server does not care about loading in additional resources as they are not send over the wire we +pass in `resources` to the `i18next` instance. This provides all the languages to your server which allows it to render +the correct language on the server. + +## Client-side + +The client-side is a bit more complicated. We do not want to load in all the languages on the client side as it would +be a lot of requests. Instead, we use the fetch backend to load in the language files on the client side. We have a resource route inside of the `routes` directory which is in charge of loading in the resources. This route is called `resource.locales` and it is used to load in the languages. The `resource.locales` route is set up to only load in the languages and namespaces that are needed. In production we cache these responses and in development we don't cache them. \ No newline at end of file diff --git a/app/localization/i18n.server.ts b/app/localization/i18n.server.ts new file mode 100644 index 0000000..fedcd40 --- /dev/null +++ b/app/localization/i18n.server.ts @@ -0,0 +1,20 @@ +import { resolve } from "node:path" +import { RemixI18Next } from "remix-i18next/server" +import i18n from "~/localization/i18n" // your i18n configuration file + +const i18next = new RemixI18Next({ + detection: { + supportedLanguages: i18n.supportedLngs, + fallbackLanguage: i18n.fallbackLng, + }, + // This is the configuration for i18next used + // when translating messages server-side only + i18next: { + ...i18n, + backend: { + loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"), + }, + }, +}) + +export default i18next diff --git a/app/localization/i18n.ts b/app/localization/i18n.ts new file mode 100644 index 0000000..ba43ac7 --- /dev/null +++ b/app/localization/i18n.ts @@ -0,0 +1,11 @@ +import { supportedLanguages } from "./resource" + +export default { + // This is the list of languages your application supports + supportedLngs: supportedLanguages, + // This is the language you want to use in case + // if the user language is not in the supportedLngs + fallbackLng: "en", + // The default namespace of i18next is "translation", but you can customize it here + defaultNS: "common", +} diff --git a/app/localization/resource.ts b/app/localization/resource.ts new file mode 100644 index 0000000..c44b8ff --- /dev/null +++ b/app/localization/resource.ts @@ -0,0 +1,19 @@ +import bosnian from "../../resources/locales/bs/common.json" +import english from "../../resources/locales/en/common.json" + +const languages = ["en", "bs"] as const +export const supportedLanguages = [...languages] +type Language = (typeof languages)[number] + +type Resource = { + common: typeof english +} + +export const resources: Record = { + en: { + common: english, + }, + bs: { + common: bosnian, + }, +} diff --git a/app/queries/user.server.ts b/app/queries/user.server.ts new file mode 100644 index 0000000..47a7aff --- /dev/null +++ b/app/queries/user.server.ts @@ -0,0 +1,14 @@ +import { db } from "~/db.server" +import { getServerSession } from "~/session.server" + +export const getUserFromRequest = async (request: Request) => { + const session = await getServerSession(request.headers.get("Cookie")) + const userId = session.get("id") + if (!userId) return null + const user = await db.player.findFirst({ + where: { + id: userId ?? 0, + }, + }) + return user +} diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..2772926 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,131 @@ +import { useTranslation } from "react-i18next" +import { + Form, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, + redirect, + useActionData, + useLoaderData, +} from "react-router" +import type { LinksFunction } from "react-router" +import { useChangeLanguage } from "remix-i18next/react" +import type { Route } from "./+types/root" +import { getUserFromRequest } from "./queries/user.server" +import { commitServerSession, getServerSession } from "./session.server" +import tailwindcss from "./tailwind.css?url" + +export async function loader({ context, request }: Route.LoaderArgs) { + const { lang, clientEnv } = context + const player = await getUserFromRequest(request) + const searchParams = new URL(request.url).searchParams + const redirectTo = searchParams.get("redirectTo") ?? undefined + + return { lang, clientEnv, player, redirectTo } +} + +export const action = async ({ context: { db }, request }: Route.ActionArgs) => { + const formData = await request.formData() + const name = formData.get("name") as string | null + const redirectTo = formData.get("redirectTo") as string | null + + if (!name) + return { + error: "Name is required", + } + const newPlayer = await db.player.create({ + data: { + name, + }, + }) + const session = await getServerSession() + session.set("id", newPlayer.id) + if (redirectTo) { + const roomId = redirectTo.split("/")[1] + await db.activePlayer.create({ + data: { + playerId: newPlayer.id, + roomId, + }, + }) + } + throw redirect(redirectTo ?? "/", { + headers: new Headers({ + "Set-Cookie": await commitServerSession(session), + }), + }) +} + +export const links: LinksFunction = () => [{ rel: "stylesheet", href: tailwindcss }] + +export const handle = { + i18n: "common", +} + +export const Layout = ({ children }: { children: React.ReactNode }) => { + const { i18n } = useTranslation() + return ( + + + + + + + + + {children} + + + + + ) +} + +function NameForm() { + const data = useActionData() + const loaderData = useLoaderData() + return ( +
+
+
+
+ + +

{data?.error}

+
+ + + +
+
+
+ ) +} + +export default function App({ loaderData }: Route.ComponentProps) { + const { lang, clientEnv, player } = loaderData + useChangeLanguage(lang) + + return ( + <> + {player !== null ? : } + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: We set the window.env variable to the client env */} +