From b09f749f5fbc025aae5a972d7cffc6b23a6de544 Mon Sep 17 00:00:00 2001 From: Stefan Probst Date: Thu, 11 May 2023 18:07:01 +0200 Subject: [PATCH] feat: add app skeleton --- .devcontainer/devcontainer.json | 19 + .dockerignore | 65 + .editorconfig | 13 + .env.local.example | 24 + .gitattributes | 1 + .github/workflows/build-deploy.yml | 125 + .github/workflows/validate.yml | 116 + .gitignore | 37 + .npmrc | 3 + .vscode/app.code-snippets | 213 + .vscode/extensions.json | 13 + .vscode/launch.json | 24 + .vscode/settings.json | 67 + Dockerfile | 60 + app/[locale]/(index)/page.tsx | 61 + app/[locale]/[...404]/page.tsx | 12 + app/[locale]/error.tsx | 12 + app/[locale]/imprint/page.tsx | 82 + app/[locale]/internal-error.tsx | 38 + app/[locale]/layout.tsx | 128 + app/[locale]/loading.tsx | 15 + app/[locale]/not-found.tsx | 48 + app/[locale]/opengraph-image.tsx | 31 + app/[locale]/providers.tsx | 28 + app/apple-icon.png | Bin 0 -> 5868 bytes app/favicon.ico | Bin 0 -> 4158 bytes app/icon.svg | 1 + app/layout.tsx | 35 + app/manifest.ts | 41 + app/not-found.tsx | 54 + app/page.tsx | 11 + app/robots.ts | 25 + app/sitemap.ts | 58 + assets/fonts/inter-semibold.ttf | Bin 0 -> 315756 bytes components/app-footer.tsx | 32 + components/app-header.tsx | 39 + components/app-layout.tsx | 13 + components/app-link.tsx | 24 + components/app-nav-link.tsx | 25 + components/color-scheme-select.tsx | 61 + components/color-scheme-switcher.tsx | 36 + components/link.tsx | 1 + components/loading-indicator.tsx | 29 + components/locale-switcher-link.tsx | 39 + components/locale-switcher.tsx | 70 + components/logo.tsx | 28 + components/main-content.tsx | 18 + components/metadata-image.tsx | 72 + components/skip-link.tsx | 34 + components/ui/button.tsx | 68 + components/ui/focus-ring.ts | 11 + components/ui/menu.tsx | 145 + components/ui/page-title.tsx | 15 + components/ui/select.tsx | 125 + config/axe.config.ts | 13 + config/env.config.js | 71 + config/i18n.config.ts | 29 + config/imprint.config.ts | 12 + config/mdx.config.js | 20 + config/syntax-highlighter.config.js | 9 + content/de/metadata/index.json | 13 + content/en/metadata/index.json | 13 + e2e/lib/fixtures/a11y.ts | 27 + e2e/lib/fixtures/i18n.ts | 29 + e2e/lib/fixtures/imprint-page.ts | 28 + e2e/lib/fixtures/index-page.ts | 28 + e2e/lib/test.ts | 50 + ...-have-visible-changes-1-chromium-linux.png | Bin 0 -> 70143 bytes ...t-have-visible-changes-1-firefox-linux.png | Bin 0 -> 124243 bytes ...ot-have-visible-changes-1-webkit-linux.png | Bin 0 -> 73500 bytes ...-have-visible-changes-2-chromium-linux.png | Bin 0 -> 66969 bytes ...t-have-visible-changes-2-firefox-linux.png | Bin 0 -> 119353 bytes ...ot-have-visible-changes-2-webkit-linux.png | Bin 0 -> 70815 bytes ...-have-visible-changes-1-chromium-linux.png | Bin 0 -> 33918 bytes ...t-have-visible-changes-1-firefox-linux.png | Bin 0 -> 60004 bytes ...ot-have-visible-changes-1-webkit-linux.png | Bin 0 -> 35516 bytes ...-have-visible-changes-2-chromium-linux.png | Bin 0 -> 28347 bytes ...t-have-visible-changes-2-firefox-linux.png | Bin 0 -> 55042 bytes ...ot-have-visible-changes-2-webkit-linux.png | Bin 0 -> 29602 bytes e2e/tests/app/analytics.test.ts | 30 + e2e/tests/app/app.test.ts | 153 + e2e/tests/app/i18n.test.ts | 136 + e2e/tests/app/metadata.test.ts | 132 + e2e/tests/global.setup.ts | 14 + e2e/tests/pages/imprint.test.ts | 51 + e2e/tests/pages/index.test.ts | 35 + lib/analytics-script.tsx | 94 + lib/color-scheme-script.tsx | 171 + lib/create-full-url.ts | 29 + lib/create-href.ts | 22 + lib/fonts.ts | 15 + lib/forward-ref.ts | 20 + lib/i18n.ts | 32 + lib/navigation.ts | 34 + lib/styles.ts | 1 + lib/use-color-scheme.ts | 40 + lib/use-id.ts | 7 + lib/use-is-client.ts | 20 + lib/use-labels.ts | 38 + lib/use-nav-link.ts | 27 + license | 18 + mdx-components.tsx | 13 + messages/de.json | 58 + messages/en.json | 58 + middleware.ts | 24 + next.config.js | 53 + package.json | 214 + playwright.config.ts | 76 + pnpm-lock.yaml | 9994 +++++++++++++++++ public/android-chrome-192x192.png | Bin 0 -> 6969 bytes public/android-chrome-512x512.png | Bin 0 -> 21004 bytes public/assets/images/logo-dark.svg | 1 + public/assets/images/logo-light.svg | 1 + public/assets/images/logo.svg | 1 + public/icon-maskable.svg | 1 + readme.md | 94 + styles/content.css | 9 + styles/index.css | 195 + tailwind.config.ts | 92 + tsconfig.json | 35 + types/i18n.d.ts | 8 + types/image.d.ts | 7 + types/react.d.ts | 8 + 123 files changed, 14748 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.local.example create mode 100644 .gitattributes create mode 100644 .github/workflows/build-deploy.yml create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .vscode/app.code-snippets create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 app/[locale]/(index)/page.tsx create mode 100644 app/[locale]/[...404]/page.tsx create mode 100644 app/[locale]/error.tsx create mode 100644 app/[locale]/imprint/page.tsx create mode 100644 app/[locale]/internal-error.tsx create mode 100644 app/[locale]/layout.tsx create mode 100644 app/[locale]/loading.tsx create mode 100644 app/[locale]/not-found.tsx create mode 100644 app/[locale]/opengraph-image.tsx create mode 100644 app/[locale]/providers.tsx create mode 100644 app/apple-icon.png create mode 100644 app/favicon.ico create mode 100644 app/icon.svg create mode 100644 app/layout.tsx create mode 100644 app/manifest.ts create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts create mode 100644 assets/fonts/inter-semibold.ttf create mode 100644 components/app-footer.tsx create mode 100644 components/app-header.tsx create mode 100644 components/app-layout.tsx create mode 100644 components/app-link.tsx create mode 100644 components/app-nav-link.tsx create mode 100644 components/color-scheme-select.tsx create mode 100644 components/color-scheme-switcher.tsx create mode 100644 components/link.tsx create mode 100644 components/loading-indicator.tsx create mode 100644 components/locale-switcher-link.tsx create mode 100644 components/locale-switcher.tsx create mode 100644 components/logo.tsx create mode 100644 components/main-content.tsx create mode 100644 components/metadata-image.tsx create mode 100644 components/skip-link.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/focus-ring.ts create mode 100644 components/ui/menu.tsx create mode 100644 components/ui/page-title.tsx create mode 100644 components/ui/select.tsx create mode 100644 config/axe.config.ts create mode 100644 config/env.config.js create mode 100644 config/i18n.config.ts create mode 100644 config/imprint.config.ts create mode 100644 config/mdx.config.js create mode 100644 config/syntax-highlighter.config.js create mode 100644 content/de/metadata/index.json create mode 100644 content/en/metadata/index.json create mode 100644 e2e/lib/fixtures/a11y.ts create mode 100644 e2e/lib/fixtures/i18n.ts create mode 100644 e2e/lib/fixtures/imprint-page.ts create mode 100644 e2e/lib/fixtures/index-page.ts create mode 100644 e2e/lib/test.ts create mode 100644 e2e/snapshots/tests/pages/imprint.test.ts-snapshots/imprint-page-should-not-have-visible-changes-1-chromium-linux.png create mode 100644 e2e/snapshots/tests/pages/imprint.test.ts-snapshots/imprint-page-should-not-have-visible-changes-1-firefox-linux.png create mode 100644 e2e/snapshots/tests/pages/imprint.test.ts-snapshots/imprint-page-should-not-have-visible-changes-1-webkit-linux.png create mode 100644 e2e/snapshots/tests/pages/imprint.test.ts-snapshots/imprint-page-should-not-have-visible-changes-2-chromium-linux.png create mode 100644 e2e/snapshots/tests/pages/imprint.test.ts-snapshots/imprint-page-should-not-have-visible-changes-2-firefox-linux.png create mode 100644 e2e/snapshots/tests/pages/imprint.test.ts-snapshots/imprint-page-should-not-have-visible-changes-2-webkit-linux.png create mode 100644 e2e/snapshots/tests/pages/index.test.ts-snapshots/index-page-should-not-have-visible-changes-1-chromium-linux.png create mode 100644 e2e/snapshots/tests/pages/index.test.ts-snapshots/index-page-should-not-have-visible-changes-1-firefox-linux.png create mode 100644 e2e/snapshots/tests/pages/index.test.ts-snapshots/index-page-should-not-have-visible-changes-1-webkit-linux.png create mode 100644 e2e/snapshots/tests/pages/index.test.ts-snapshots/index-page-should-not-have-visible-changes-2-chromium-linux.png create mode 100644 e2e/snapshots/tests/pages/index.test.ts-snapshots/index-page-should-not-have-visible-changes-2-firefox-linux.png create mode 100644 e2e/snapshots/tests/pages/index.test.ts-snapshots/index-page-should-not-have-visible-changes-2-webkit-linux.png create mode 100644 e2e/tests/app/analytics.test.ts create mode 100644 e2e/tests/app/app.test.ts create mode 100644 e2e/tests/app/i18n.test.ts create mode 100644 e2e/tests/app/metadata.test.ts create mode 100644 e2e/tests/global.setup.ts create mode 100644 e2e/tests/pages/imprint.test.ts create mode 100644 e2e/tests/pages/index.test.ts create mode 100644 lib/analytics-script.tsx create mode 100644 lib/color-scheme-script.tsx create mode 100644 lib/create-full-url.ts create mode 100644 lib/create-href.ts create mode 100644 lib/fonts.ts create mode 100644 lib/forward-ref.ts create mode 100644 lib/i18n.ts create mode 100644 lib/navigation.ts create mode 100644 lib/styles.ts create mode 100644 lib/use-color-scheme.ts create mode 100644 lib/use-id.ts create mode 100644 lib/use-is-client.ts create mode 100644 lib/use-labels.ts create mode 100644 lib/use-nav-link.ts create mode 100644 license create mode 100644 mdx-components.tsx create mode 100644 messages/de.json create mode 100644 messages/en.json create mode 100644 middleware.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 public/android-chrome-192x192.png create mode 100644 public/android-chrome-512x512.png create mode 100644 public/assets/images/logo-dark.svg create mode 100644 public/assets/images/logo-light.svg create mode 100644 public/assets/images/logo.svg create mode 100644 public/icon-maskable.svg create mode 100644 readme.md create mode 100644 styles/content.css create mode 100644 styles/index.css create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types/i18n.d.ts create mode 100644 types/image.d.ts create mode 100644 types/react.d.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..cfb08a4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "Next.js", + "image": "ghcr.io/acdh-oeaw/devcontainer-frontend:20", + "customizations": { + "vscode": { + "extensions": [ + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "lokalise.i18n-ally", + "mikestead.dotenv", + "ms-playwright.playwright", + "stylelint.vscode-stylelint", + "unifiedjs.vscode-mdx" + ] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eae158f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,65 @@ +## .gitignore ## + +# dependencies +node_modules/ +.pnpm-store/ + +# logs +*.log + +# non-public environment variables +.env.local +.env.*.local + +# caches +.eslintcache +.prettiercache +.stylelintcache +*.tsbuildinfo + +# vercel +.vercel + +# misc +.DS_Store +.idea/ + +# next.js +.next/ +next-env.d.ts +out/ + +# test +/coverage/ + +# playwright +/blob-report/ +/playwright/.cache/ +/playwright-report/ +/test-results/ + + +## .dockerignore ## + +# git +.git/ +.gitattributes +.gitignore + +# github +.github/ + +# vscode settings +.vscode/ + +# environment variables +.env +.env.* + +# tests +playwright.config.ts +/e2e/ +/test/ + +# misc +.editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7d73f1e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = tab +insert_final_newline = true +max_line_length = 100 +trim_trailing_whitespace = true + +[*.{yaml,yml}] +indent_style = space diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..5976030 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,24 @@ +# ------------------------------------------------------------------------------------------------- +# environment variables +# ------------------------------------------------------------------------------------------------- +# - public environment variables must be prefixed with `NEXT_PUBLIC_`. +# - when adding new environment variables, don't forget to also update the +# validation schema in `./config/env.config.js`. + +# ------------------------------------------------------------------------------------------------- +# app +# ------------------------------------------------------------------------------------------------- +NEXT_PUBLIC_APP_BASE_URL="http://localhost:3000" +# imprint service +NEXT_PUBLIC_REDMINE_ID= +# web crawlers +NEXT_PUBLIC_BOTS="disabled" +# validate environment variables +ENV_VALIDATION="enabled" + +# ------------------------------------------------------------------------------------------------- +# analytics +# ------------------------------------------------------------------------------------------------- +# NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION= +NEXT_PUBLIC_MATOMO_BASE_URL="https://matomo.acdh.oeaw.ac.at" +# NEXT_PUBLIC_MATOMO_ID= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml new file mode 100644 index 0000000..d3db780 --- /dev/null +++ b/.github/workflows/build-deploy.yml @@ -0,0 +1,125 @@ +name: Build and deploy + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}-build-deploy" + cancel-in-progress: true + +on: + workflow_call: + workflow_dispatch: + +jobs: + env: + name: Generate environment variables + runs-on: ubuntu-latest + steps: + - name: Derive environment from git ref + id: environment + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + ENVIRONMENT="production" + APP_NAME_SUFFIX="" + elif [ "${{ github.ref }}" = "refs/heads/develop" ]; then + ENVIRONMENT="development" + APP_NAME_SUFFIX="-development" + elif [ "${{github.event_name}}" = "pull_request"]; then + ENVIRONMENT="pr/${{ github.event.pull_request.number }}" + APP_NAME_SUFFIX="-pr-${{ github.event.pull_request.number }}" + else + exit 1 + fi + + echo "ENVIRONMENT=$ENVIRONMENT" >> $GITHUB_OUTPUT + echo "APP_NAME_SUFFIX=$APP_NAME_SUFFIX" >> $GITHUB_OUTPUT + outputs: + environment: "${{ steps.environment.outputs.ENVIRONMENT }}" + app_name: "frontend${{ steps.environment.outputs.APP_NAME_SUFFIX }}" + registry: "ghcr.io" + image: "${{ github.repository }}" + + vars: + name: Generate public url + needs: [env] + runs-on: ubuntu-latest + environment: + name: "${{ needs.env.outputs.environment }}" + steps: + - name: Generate public URL + id: public_url + run: | + if [ -z "${{ vars.PUBLIC_URL }}" ]; then + PUBLIC_URL="https://${{ needs.env.outputs.app_name }}.${{ vars.KUBE_INGRESS_BASE_DOMAIN }}" + else + PUBLIC_URL="${{ vars.PUBLIC_URL }}" + fi + + echo "PUBLIC_URL=$PUBLIC_URL" >> $GITHUB_OUTPUT + outputs: + public_url: "${{ steps.public_url.outputs.PUBLIC_URL }}" + + build: + name: Build and push docker image + needs: [env, vars] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + environment: + name: "${{ needs.env.outputs.environment }}" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: "${{ needs.env.outputs.registry }}" + username: "${{ github.actor }}" + password: "${{ secrets.GITHUB_TOKEN }}" + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: "${{ needs.env.outputs.registry }}/${{ needs.env.outputs.image }}" + tags: | + type=raw,value={{sha}} + type=ref,event=branch + # type=ref,event=pr + # type=semver,pattern={{version}} + # type=semver,pattern={{major}}.{{minor}} + # type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: "${{ steps.meta.outputs.tags }}" + labels: "${{ steps.meta.outputs.labels }}" + build-args: | + "NEXT_PUBLIC_APP_BASE_URL=${{ needs.vars.outputs.public_url }}" + "NEXT_PUBLIC_BOTS=${{ vars.NEXT_PUBLIC_BOTS }}" + "NEXT_PUBLIC_MATOMO_BASE_URL=${{ vars.NEXT_PUBLIC_MATOMO_BASE_URL }}" + "NEXT_PUBLIC_MATOMO_ID=${{ vars.NEXT_PUBLIC_MATOMO_ID }}" + "NEXT_PUBLIC_REDMINE_ID=${{ vars.SERVICE_ID }}" + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Deploy docker image + needs: [env, vars, build] + uses: acdh-oeaw/gl-autodevops-minimal-port/.github/workflows/deploy.yml@main + secrets: inherit + with: + environment: "${{ needs.env.outputs.environment }}" + DOCKER_TAG: "${{ needs.env.outputs.registry }}/${{ needs.env.outputs.image }}" + APP_NAME: "${{ needs.env.outputs.app_name }}" + APP_ROOT: "/" + SERVICE_ID: "${{ vars.SERVICE_ID }}" + PUBLIC_URL: "${{ needs.vars.outputs.public_url }}" + default_port: "3000" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..49f4360 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,116 @@ +name: Validate + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}-validate" + cancel-in-progress: true + +on: + workflow_dispatch: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + validate: + name: Validate + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + + strategy: + fail-fast: true + matrix: + node-version: [20.x] + os: [ubuntu-latest] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Necessary because `actions/setup-node` does not yet support `corepack`. + # @see https://github.com/actions/setup-node/issues/531 + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint commit messages + uses: acdh-oeaw/commitlint-action@v1 + + - name: Format + run: pnpm run format:check + + - name: Lint + run: pnpm run lint:check + + - name: Typecheck + run: pnpm run types:check + + - name: Test + run: pnpm run test + + - name: Get playwright version + run: | + PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --json | jq --raw-output '.[0].devDependencies["@playwright/test"].version') + echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV + + - name: Cache playwright browsers + uses: actions/cache@v4 + id: cache-playwright-browsers + with: + path: "~/.cache/ms-playwright" + key: "${{ matrix.os }}-playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}" + + - name: Install playwright browsers + if: steps.cache-playwright-browsers.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps + - name: Install playwright browsers (operating system dependencies) + if: steps.cache-playwright-browsers.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps + + # https://nextjs.org/docs/pages/building-your-application/deploying/ci-build-caching#github-actions + - name: Cache Next.js build output + uses: actions/cache@v4 + with: + path: "${{ github.workspace }}/.next/cache" + key: "${{ matrix.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}" + + - name: Build app + run: pnpm run build + env: + NEXT_PUBLIC_APP_BASE_URL: "http://localhost:3000" + NEXT_PUBLIC_MATOMO_BASE_URL: "${{ vars.NEXT_PUBLIC_MATOMO_BASE_URL }}" + NEXT_PUBLIC_REDMINE_ID: "${{ vars.SERVICE_ID }}" + + - name: Run e2e tests + run: pnpm run test:e2e + env: + NEXT_PUBLIC_APP_BASE_URL: "http://localhost:3000" + NEXT_PUBLIC_MATOMO_BASE_URL: "${{ vars.NEXT_PUBLIC_MATOMO_BASE_URL }}" + NEXT_PUBLIC_REDMINE_ID: "${{ vars.SERVICE_ID }}" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + build-deploy: + if: ${{ github.event_name == 'push' }} + needs: [validate] + uses: ./.github/workflows/build-deploy.yml + secrets: inherit + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#access-and-permissions + permissions: + contents: read + packages: write diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65f477f --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# dependencies +node_modules/ +.pnpm-store/ + +# logs +*.log + +# non-public environment variables +.env.local +.env.*.local + +# caches +.eslintcache +.prettiercache +.stylelintcache +*.tsbuildinfo + +# vercel +.vercel + +# misc +.DS_Store +.idea/ + +# next.js +.next/ +next-env.d.ts +out/ + +# test +/coverage/ + +# playwright +/blob-report/ +/playwright/.cache/ +/playwright-report/ +/test-results/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..cd00af4 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +engine-strict=true +package-manager-strict=false +shell-emulator=true diff --git a/.vscode/app.code-snippets b/.vscode/app.code-snippets new file mode 100644 index 0000000..b5c67de --- /dev/null +++ b/.vscode/app.code-snippets @@ -0,0 +1,213 @@ +{ + "Next.js static page component": { + "scope": "typescriptreact", + "prefix": "next-page-static", + "body": [ + "import type { Metadata, ResolvingMetadata } from \"next\";", + "import { useTranslations } from \"next-intl\";", + "import { getTranslations, unstable_setRequestLocale as setRequestLocale } from \"next-intl/server\";", + "import type { ReactNode } from \"react\";", + "", + "import { MainContent } from \"@/components/main-content\";", + "import { PageTitle } from \"@/components/ui/page-title\";", + "import type { Locale } from \"@/config/i18n.config\";", + "", + "interface ${1:Name}PageProps {", + "\tparams: {", + "\t\tlocale: Locale;", + "\t};", + "}", + "", + "export async function generateMetadata(", + "\tprops: ${1:Name}PageProps,", + "\t_parent: ResolvingMetadata,", + "): Promise {", + "\tconst { params } = props;", + "", + "\tconst { locale } = params;", + "\tconst t = await getTranslations({ locale, namespace: \"${1:Name}Page\" });", + "", + "\tconst metadata: Metadata = {", + "\t\ttitle: t(\"meta.title\"),", + "\t};", + "", + "\treturn metadata;", + "}", + "", + "export default function ${1:Name}Page(props: ${1:Name}PageProps): ReactNode {", + "\tconst { params } = props;", + "", + "\tconst { locale } = params;", + "\tsetRequestLocale(locale);", + "", + "\tconst t = useTranslations(\"${1:Name}Page\");", + "", + "\treturn (", + "\t\t", + "\t\t\t{t(\"title\")}$0", + "\t\t", + "\t);", + "}", + ], + "description": "Create Next.js static page component", + }, + "Next.js dynamic page component": { + "scope": "typescriptreact", + "prefix": "next-page-dynamic", + "body": [ + "import type { Metadata, ResolvingMetadata } from \"next\";", + "import { useTranslations } from \"next-intl\";", + "import { getTranslations, unstable_setRequestLocale as setRequestLocale } from \"next-intl/server\";", + "import type { ReactNode } from \"react\";", + "", + "import { MainContent } from \"@/components/main-content\";", + "import { PageTitle } from \"@/components/ui/page-title\";", + "import type { Locale } from \"@/config/i18n.config\";", + "", + "interface ${1:Name}PageProps {", + "\tparams: {", + "\t\tid: string;", + "\t\tlocale: Locale;", + "\t};", + "}", + "", + "export const dynamicParams = false;", + "", + "export async function generateStaticParams(props: {", + "\tparams: Pick<${1:Name}PageProps[\"params\"], \"locale\">;", + "}): Promise>> {", + "\tconst { params } = props;", + "", + "\tconst { locale } = params;", + "\tconst ids = await Promise.resolve([])", + "", + "\treturn ids.map((id) => {", + "\t\treturn { id };", + "\t});", + "}", + "", + "export async function generateMetadata(", + "\tprops: ${1:Name}PageProps,", + "\t_parent: ResolvingMetadata,", + "): Promise {", + "\tconst { params } = props;", + "", + "\tconst { id, locale } = params;", + "\tconst t = await getTranslations({ locale, namespace: \"${1:Name}Page\" });", + "", + "\tconst metadata: Metadata = {", + "\t\ttitle: t(\"meta.title\"),", + "\t};", + "", + "\treturn metadata;", + "}", + "", + "export default function ${1:Name}Page(props: ${1:Name}PageProps): ReactNode {", + "\tconst { params } = props;", + "", + "\tconst { id, locale } = params;", + "\tsetRequestLocale(locale);", + "", + "\tconst t = useTranslations(\"${1:Name}Page\");", + "", + "\treturn (", + "\t\t", + "\t\t\t{t(\"title\")}$0", + "\t\t", + "\t);", + "}", + ], + "description": "Create Next.js dynamic page component.", + }, + "Next.js layout component": { + "scope": "typescriptreact", + "prefix": "next-layout", + "body": [ + "import { unstable_setRequestLocale as setRequestLocale } from \"next-intl/server\";", + "import type { ReactNode } from \"react\";", + "", + "import type { Locale } from \"@/config/i18n.config\";", + "", + "interface ${1:Name}LayoutProps {", + "\tchildren: ReactNode;", + "\tparams: {", + "\t\tlocale: Locale;", + "\t};", + "}", + "", + "export default function ${1:Name}Layout(props: ${1:Name}LayoutProps): ReactNode {", + "\tconst { children, params } = props;", + "", + "\tconst { locale } = params;", + "\tsetRequestLocale(locale);", + "\t", + "\treturn (", + "\t\t
{children}
$0", + "\t)", + "}", + ], + "description": "Create Next.js layout component.", + }, + "React component without props": { + "prefix": "next-component", + "body": [ + "import type { ReactNode } from \"react\";", + "", + "export function ${1:Name}(): ReactNode {", + "\t$0", + "\treturn null;", + "}", + ], + "description": "Create React component without props.", + }, + "React component with props": { + "prefix": "next-component-props", + "body": [ + "import type { ReactNode } from \"react\";", + "", + "interface ${1:Name}Props {", + "\t$2", + "}", + "", + "export function ${1:Name}(props: ${1:Name}Props): ReactNode {", + "\tconst { $3 } = props;", + "\t$0", + "\treturn null;", + "}", + ], + "description": "Create React component with props.", + }, + "React component with children": { + "prefix": "next-component-children", + "body": [ + "import type { ReactNode } from \"react\";", + "", + "interface ${1:Name}Props {", + "\tchildren: ReactNode;", + "\t$2", + "}", + "", + "export function ${1:Name}(props: ${1:Name}Props): ReactNode {", + "\tconst { children, $3 } = props;", + "\t$0", + "\treturn null;", + "}", + ], + "description": "Create React component with children.", + }, + "React \"use client\" directive": { + "prefix": "next-use-client", + "body": ["\"use client\";"], + "description": "Add \"use client\" directive.", + }, + "React \"use server\" directive": { + "prefix": "next-use-server", + "body": ["\"use server\";"], + "description": "Add \"use server\" directive.", + }, + "Next.js \"server-only\" poison pill": { + "prefix": "next-server-only", + "body": ["import \"server-only\";"], + "description": "Add \"server-only\" import.", + }, +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c892513 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + "recommendations": [ + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "lokalise.i18n-ally", + "mikestead.dotenv", + "ms-playwright.playwright", + "stylelint.vscode-stylelint", + "unifiedjs.vscode-mdx" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..98a64ed --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "configurations": [ + { + "name": "Apps: Web (server-side)", + "request": "launch", + "runtimeArgs": ["run", "dev"], + "runtimeExecutable": "pnpm", + "skipFiles": ["/**"], + "type": "node" + }, + { + "name": "Apps: Web (client-side)", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + } + ], + "compounds": [ + { + "name": "Apps: Web", + "configurations": ["Apps: Web (server-side)", "Apps: Web (client-side)"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d03d496 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,67 @@ +{ + "css.validate": false, + "debug.toolBarLocation": "docked", + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.inlayHints.enabled": "offUnlessPressed", + "editor.linkedEditing": true, + "editor.quickSuggestions": { + "strings": true + }, + "editor.rulers": [100], + "editor.stickyScroll.enabled": true, + "eslint.enable": true, + "eslint.options": { + "ignorePath": ".gitignore" + }, + "eslint.validate": ["javascript", "typescript", "typescriptreact"], + "files.associations": { + "*.css": "tailwindcss" + }, + "files.eol": "\n", + "i18n-ally.annotations": false, + "i18n-ally.keepFulfilled": true, + "i18n-ally.keystyle": "nested", + "i18n-ally.localesPaths": ["./messages"], + "i18n-ally.review.enabled": false, + "i18n-ally.sortKeys": true, + "i18n-ally.tabStyle": "tab", + "less.validate": false, + "mdx.server.enable": true, + "prettier.ignorePath": ".gitignore", + "scss.validate": false, + "stylelint.enable": true, + "stylelint.snippet": ["css", "postcss", "tailwindcss"], + "stylelint.validate": ["css", "postcss", "tailwindcss"], + "tailwindCSS.experimental.classRegex": [ + ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], + ["variants\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] + ], + "tailwindCSS.validate": true, + "terminal.integrated.enablePersistentSessions": false, + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.inlayHints.parameterNames.enabled": "all", + "typescript.preferences.autoImportFileExcludePatterns": [ + "next/router.d.ts", + "next/dist/client/router.d.ts" + ], + "typescript.preferences.importModuleSpecifier": "non-relative", + "typescript.preferences.preferTypeOnlyAutoImports": true, + "typescript.tsdk": "node_modules/typescript/lib", + "workbench.editor.customLabels.patterns": { + "**/app/**/page.tsx": "${dirname} - Page", + "**/app/**/layout.tsx": "${dirname} - Layout" + }, + "workbench.editor.labelFormat": "medium", + "workbench.tree.enableStickyScroll": true, + "[markdown]": { + "editor.wordWrap": "on" + }, + "[mdx]": { + "editor.formatOnSave": false, + "editor.wordWrap": "on" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43f4675 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1 + +# using alpine base image to avoid `sharp` memory leaks. +# @see https://sharp.pixelplumbing.com/install#linux-memory-allocator + +# build +FROM node:20-alpine AS build + +RUN corepack enable + +RUN mkdir /app && chown -R node:node /app +WORKDIR /app + +USER node + +COPY --chown=node:node .npmrc package.json pnpm-lock.yaml ./ + +RUN pnpm fetch + +COPY --chown=node:node ./ ./ + +ARG NEXT_PUBLIC_APP_BASE_URL +ARG NEXT_PUBLIC_BOTS +ARG NEXT_PUBLIC_MATOMO_BASE_URL +ARG NEXT_PUBLIC_MATOMO_ID +ARG NEXT_PUBLIC_REDMINE_ID + +# disable validation for runtime environment variables +ENV ENV_VALIDATION=public + +RUN pnpm install --frozen-lockfile --offline + +ENV BUILD_MODE=standalone +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# mount secrets which need to be available at build time +RUN pnpm run build + +# serve +FROM node:20-alpine AS serve + +RUN mkdir /app && chown -R node:node /app +WORKDIR /app + +USER node + +COPY --from=build --chown=node:node /app/next.config.js ./ +COPY --from=build --chown=node:node /app/public ./public +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static + +# Ensure folder is owned by node:node when mounted as volume. +RUN mkdir -p /app/.next/cache/images + +ENV NODE_ENV=production + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/app/[locale]/(index)/page.tsx b/app/[locale]/(index)/page.tsx new file mode 100644 index 0000000..1810473 --- /dev/null +++ b/app/[locale]/(index)/page.tsx @@ -0,0 +1,61 @@ +import type { Metadata, ResolvingMetadata } from "next"; +import { useTranslations } from "next-intl"; +import { getTranslations, unstable_setRequestLocale as setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; + +import { Logo } from "@/components/logo"; +import { MainContent } from "@/components/main-content"; +import type { Locale } from "@/config/i18n.config"; + +interface IndexPageProps { + params: { + locale: Locale; + }; +} + +export async function generateMetadata( + props: IndexPageProps, + _parent: ResolvingMetadata, +): Promise { + const { params } = props; + + const { locale } = params; + const _t = await getTranslations({ locale, namespace: "IndexPage" }); + + const metadata: Metadata = { + /** + * Fall back to `title.default` from `layout.tsx`. + * + * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#title + */ + // title: undefined, + }; + + return metadata; +} + +export default function IndexPage(props: IndexPageProps): ReactNode { + const { params } = props; + + const { locale } = params; + setRequestLocale(locale); + + const t = useTranslations("IndexPage"); + + return ( + +
+
+ + {t("badge")} +
+

+ {t("title")} +

+
+ {t("lead-in")} +
+
+
+ ); +} diff --git a/app/[locale]/[...404]/page.tsx b/app/[locale]/[...404]/page.tsx new file mode 100644 index 0000000..372ffbe --- /dev/null +++ b/app/[locale]/[...404]/page.tsx @@ -0,0 +1,12 @@ +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; + +/** + * Only a root `not-found.tsx` automatically handles unmatched URLs. + * Since we want localised 404 pages, we need this manual trigger in a catch-all route. + * + * @see https://nextjs.org/docs/app/api-reference/file-conventions/not-found + */ +export default function NotFoundPage(): ReactNode { + notFound(); +} diff --git a/app/[locale]/error.tsx b/app/[locale]/error.tsx new file mode 100644 index 0000000..b480a0d --- /dev/null +++ b/app/[locale]/error.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { lazy } from "react"; + +/** + * Defer loading i18n functionality client-side until needed. + * + * @see https://next-intl-docs.vercel.app/docs/environments/error-files#errorjs + */ +export default lazy(async () => { + return import("@/app/[locale]/internal-error"); +}); diff --git a/app/[locale]/imprint/page.tsx b/app/[locale]/imprint/page.tsx new file mode 100644 index 0000000..f8d2f72 --- /dev/null +++ b/app/[locale]/imprint/page.tsx @@ -0,0 +1,82 @@ +import { HttpError, request } from "@acdh-oeaw/lib"; +import type { Metadata, ResolvingMetadata } from "next"; +import { notFound } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { getTranslations, unstable_setRequestLocale as setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; + +import { MainContent } from "@/components/main-content"; +import { PageTitle } from "@/components/ui/page-title"; +import type { Locale } from "@/config/i18n.config"; +import { createImprintUrl } from "@/config/imprint.config"; + +interface ImprintPageProps { + params: { + locale: Locale; + }; +} + +export async function generateMetadata( + props: ImprintPageProps, + _parent: ResolvingMetadata, +): Promise { + const { params } = props; + + const { locale } = params; + const t = await getTranslations({ locale, namespace: "ImprintPage" }); + + const metadata: Metadata = { + title: t("meta.title"), + }; + + return metadata; +} + +export default function ImprintPage(props: ImprintPageProps): ReactNode { + const { params } = props; + + const { locale } = params; + setRequestLocale(locale); + + const t = useTranslations("ImprintPage"); + + return ( + + {/* @ts-expect-error @see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/69970 */} + + + ); +} + +interface ImprintPageContentProps { + locale: Locale; + title: string; +} + +async function ImprintPageContent(props: ImprintPageContentProps): Promise> { + const { locale, title } = props; + + const html = await getImprintHtml(locale); + + return ( +
+ {title} +
+
+ ); +} + +async function getImprintHtml(locale: Locale): Promise { + try { + const url = createImprintUrl(locale); + const html = await request(url, { responseType: "text" }); + + return html as string; + } catch (error) { + if (error instanceof HttpError && error.response.status === 404) { + notFound(); + } + + throw error; + } +} diff --git a/app/[locale]/internal-error.tsx b/app/[locale]/internal-error.tsx new file mode 100644 index 0000000..6b36ce1 --- /dev/null +++ b/app/[locale]/internal-error.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { type ReactNode, useEffect } from "react"; + +import { MainContent } from "@/components/main-content"; +import { PageTitle } from "@/components/ui/page-title"; + +interface InternalErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +/** `React.lazy` requires default export. */ +// eslint-disable-next-line import-x/no-default-export +export default function InternalError(props: InternalErrorProps): ReactNode { + const { error, reset } = props; + + const t = useTranslations("Error"); + + useEffect(() => { + console.error(error); + }, [error]); + + return ( + + {t("something-went-wrong")} + + + ); +} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..381f3c3 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,128 @@ +import { pick } from "@acdh-oeaw/lib"; +import type { Metadata, ResolvingMetadata } from "next"; +import { useMessages, useTranslations } from "next-intl"; +import { getTranslations, unstable_setRequestLocale as setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; +import { LocalizedStringProvider as Translations } from "react-aria-components/i18n"; +import { jsonLdScriptProps } from "react-schemaorg"; + +import { Providers } from "@/app/[locale]/providers"; +import { AppFooter } from "@/components/app-footer"; +import { AppHeader } from "@/components/app-header"; +import { AppLayout } from "@/components/app-layout"; +import { id } from "@/components/main-content"; +import { SkipLink } from "@/components/skip-link"; +import { env } from "@/config/env.config"; +import { type Locale, locales } from "@/config/i18n.config"; +import { AnalyticsScript } from "@/lib/analytics-script"; +import { ColorSchemeScript } from "@/lib/color-scheme-script"; +import * as fonts from "@/lib/fonts"; +import { cn } from "@/lib/styles"; + +interface LocaleLayoutProps { + children: ReactNode; + params: { + locale: Locale; + }; +} + +export const dynamicParams = false; + +export function generateStaticParams(): Array { + return locales.map((locale) => { + return { locale }; + }); +} + +export async function generateMetadata( + props: Omit, + _parent: ResolvingMetadata, +): Promise { + const { params } = props; + + const { locale } = params; + const meta = await getTranslations({ locale, namespace: "metadata" }); + + const metadata: Metadata = { + title: { + default: meta("title"), + template: ["%s", meta("title")].join(" | "), + }, + description: meta("description"), + openGraph: { + title: meta("title"), + description: meta("description"), + url: "./", + siteName: meta("title"), + locale, + type: "website", + }, + twitter: { + card: "summary_large_image", + creator: meta("twitter.creator"), + site: meta("twitter.site"), + }, + verification: { + google: env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION, + }, + }; + + return metadata; +} + +export default function LocaleLayout(props: LocaleLayoutProps): ReactNode { + const { children, params } = props; + + const { locale } = params; + setRequestLocale(locale); + + const t = useTranslations("LocaleLayout"); + const meta = useTranslations("metadata"); + const messages = useMessages() as IntlMessages; + + return ( + + + {/* @see https://nextjs.org/docs/app/building-your-application/optimizing/metadata#json-ld */} +