diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4f0c884 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "Next.js", + "image": "ghcr.io/acdh-oeaw/devcontainer-frontend:22", + "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" + ] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff92674 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,66 @@ +## .gitignore ## + +# dependencies +node_modules/ +.pnpm-store/ + +# logs +*.log + +# non-public environment variables +.env.keys +.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..9f7ba80 --- /dev/null +++ b/.github/workflows/build-deploy.yml @@ -0,0 +1,126 @@ +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-22.04 + 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-22.04 + 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-22.04 + 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@v6 + 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_GOOGLE_SITE_VERIFICATION=${{ vars.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION }}" + "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..ce9ec5f --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,114 @@ +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: [22.x] + os: [ubuntu-22.04] + + 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: 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: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + build-deploy: + name: Build and 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..fd5aba6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# dependencies +node_modules/ +.pnpm-store/ + +# logs +*.log + +# non-public environment variables +.env.keys +.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..92db421 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +engine-strict=true +manage-package-manager-versions=true +package-manager-strict=false +shell-emulator=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9d631e6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# package manager lock file +pnpm-lock.yaml diff --git a/.vscode/app.code-snippets b/.vscode/app.code-snippets new file mode 100644 index 0000000..7c163b0 --- /dev/null +++ b/.vscode/app.code-snippets @@ -0,0 +1,227 @@ +{ + "Next.js static page component": { + "scope": "typescriptreact", + "prefix": "next-page-static", + "body": [ + "import type { Metadata, ResolvingMetadata } from \"next\";", + "import { getTranslations, setRequestLocale } from \"next-intl/server\";", + "import type { ReactNode } from \"react\";", + "", + "import { MainContent } from \"@/components/main-content\";", + "import type { Locale } from \"@/config/i18n.config\";", + "", + "interface ${1:Name}PageProps {", + "\tparams: {", + "\t\tlocale: Locale;", + "\t};", + "}", + "", + "export async function generateMetadata(", + "\tprops: Readonly<${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 async function ${1:Name}Page(props: Readonly<${1:Name}PageProps>): Promise {", + "\tconst { params } = props;", + "", + "\tconst { locale } = params;", + "", + "\tsetRequestLocale(locale);", + "", + "\tconst t = await getTranslations(\"${1:Name}Page\");", + "", + "\treturn (", + "\t\t", + "\t\t\t
", + "\t\t\t\t

{t(\"title\")}

$0", + "\t\t\t
", + "\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 { getTranslations, setRequestLocale } from \"next-intl/server\";", + "import type { ReactNode } from \"react\";", + "", + "import { MainContent } from \"@/components/main-content\";", + "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\t/** @see https://github.com/vercel/next.js/issues/63002 */", + "\t\treturn { id: process.env.NODE_ENV === \"production\" ? id : encodeURIComponent(id) };", + "\t});", + "}", + "", + "export async function generateMetadata(", + "\tprops: Readonly${1:Name}PageProps>,", + "\t_parent: ResolvingMetadata,", + "): Promise {", + "\tconst { params } = props;", + "", + "\tconst { locale } = params;", + "\tconst id = decodeURIComponent(params.id);", + "", + "\tconst t = await getTranslations({ locale, namespace: \"${1:Name}Page\" });", + "", + "\tconst metadata: Metadata = {", + "\t\ttitle: t(\"meta.title\"),", + "\t};", + "", + "\treturn metadata;", + "}", + "", + "export default async function ${1:Name}Page(props: Readonly<${1:Name}PageProps>): Promise {", + "\tconst { params } = props;", + "", + "\tconst { locale } = params;", + "\tconst id = decodeURIComponent(params.id);", + "", + "\tsetRequestLocale(locale);", + "", + "\tconst t = await getTranslations(\"${1:Name}Page\");", + "", + "\treturn (", + "\t\t", + "\t\t\t
", + "\t\t\t\t

{t(\"title\")}

$0", + "\t\t\t
", + "\t\t
", + "\t);", + "}", + ], + "description": "Create Next.js dynamic page component.", + }, + "Next.js layout component": { + "scope": "typescriptreact", + "prefix": "next-layout", + "body": [ + "import { 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 async function ${1:Name}Layout(props: Readonly<${1:Name}LayoutProps>): Promise {", + "\tconst { children, params } = props;", + "", + "\tconst { locale } = params;", + "\t", + "\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: Readonly<${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: Readonly<${1:Name}Props>): ReactNode {", + "\tconst { children, $3 } = props;", + "\t$0", + "\treturn null;", + "}", + ], + "description": "Create React component with children.", + }, + "React \"use cache\" directive": { + "prefix": "next-use-cache", + "body": ["\"use cache\";"], + "description": "Add \"use cache\" directive.", + }, + "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..6898c6a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "lokalise.i18n-ally", + "mikestead.dotenv", + "ms-playwright.playwright", + "stylelint.vscode-stylelint" + ] +} 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..10396d1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,65 @@ +{ + "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.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, + "prettier.ignorePath": ".gitignore", + "scss.validate": false, + "stylelint.enable": true, + "stylelint.snippet": ["css", "postcss", "tailwindcss"], + "stylelint.validate": ["css", "postcss", "tailwindcss"], + "tailwindCSS.experimental.classRegex": [ + ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], + ["styles\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] + ], + "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/**/error.tsx": "${dirname} - error", + "**/app/**/forbidden.tsx": "${dirname} - forbidden", + "**/app/**/layout.tsx": "${dirname} - layout", + "**/app/**/loading.tsx": "${dirname} - loading", + "**/app/**/not-found.tsx": "${dirname} - not-found", + "**/app/**/page.tsx": "${dirname} - page", + "**/app/**/template.tsx": "${dirname} - template", + "**/app/**/unauthorized.tsx": "${dirname} - unauthorized" + }, + "workbench.editor.labelFormat": "medium", + "workbench.tree.enableStickyScroll": true, + "[markdown]": { + "editor.wordWrap": "on" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..75da0cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# 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:22-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_GOOGLE_SITE_VERIFICATION +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 + +# to mount secrets which need to be available at build time +# @see https://docs.docker.com/build/building/secrets/ +RUN pnpm run build + +# serve +FROM node:22-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..256c490 --- /dev/null +++ b/app/[locale]/(index)/page.tsx @@ -0,0 +1,148 @@ +import { ArrowRightIcon } from "lucide-react"; +import type { Metadata, ResolvingMetadata } from "next"; +import { useTranslations } from "next-intl"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; + +import { Image } from "@/components/image"; +import { Link } from "@/components/link"; +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: Readonly, + _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; +} + +// eslint-disable-next-line @typescript-eslint/require-await +export default async function IndexPage(props: Readonly): Promise { + const { params } = props; + + const { locale } = params; + setRequestLocale(locale); + + return ( + + + + + ); +} + +function HeroSection(): ReactNode { + const t = useTranslations("IndexPage"); + + return ( +
+
+ + + {t("badge")} + +

+ {t("title")} +

+

+ {t("lead-in")} +

+
+
+ ); +} + +function FeaturesSection(): ReactNode { + const _t = useTranslations("IndexPage"); + + const features = { + i18n: { title: "Internationalisation" }, + e2e: { title: "End-to-end tests" }, + "dark-mode": { title: "Dark mode" }, + cms: { title: "Content management system" }, + feed: { title: "RSS Feed" }, + authentication: { title: "Authentication" }, + }; + + return ( +
+
+

+ Features +

+

+ This template comes with important features built in. +

+
+ +
    + {Object.entries(features).map(([id, feature]) => { + return ( +
  • +
    + +
    +
    +

    + {feature.title} +

    +

    + Excepteur eiusmod dolor eu ut nulla cillum nisi irure proident. Reprehenderit + irure voluptate ex consectetur magna quis aute quis eiusmod sunt in in elit. +

    +
    +
    +
    +
  • + ); + })} +
+ +
+ + See all + + +
+
+ ); +} diff --git a/app/[locale]/[...notfound]/page.tsx b/app/[locale]/[...notfound]/page.tsx new file mode 100644 index 0000000..372ffbe --- /dev/null +++ b/app/[locale]/[...notfound]/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]/_components/app-footer.tsx b/app/[locale]/_components/app-footer.tsx new file mode 100644 index 0000000..b35c310 --- /dev/null +++ b/app/[locale]/_components/app-footer.tsx @@ -0,0 +1,125 @@ +import { useLocale, useTranslations } from "next-intl"; +import type { FC, ReactNode } from "react"; + +import { + // BlueskyLogo, + MastodonLogo, + TwitterLogo, + YouTubeLogo, +} from "@/app/[locale]/_components/social-media-logos"; +import type { LinkProps } from "@/components/link"; +import { Logo } from "@/components/logo"; +import { NavLink } from "@/components/nav-link"; +import type { Locale } from "@/config/i18n.config"; +import { createHref } from "@/lib/create-href"; + +export function AppFooter(): ReactNode { + const locale = useLocale(); + const t = useTranslations("AppFooter"); + + const links = { + contact: { + href: createHref({ pathname: "/contact" }), + label: t("links.contact"), + }, + imprint: { + href: createHref({ pathname: "/imprint" }), + label: t("links.imprint"), + }, + } satisfies Record; + + const socialMedia = { + // bluesky: { + // href: "https://bsky.app/acdh_oeaw", + // label: t("social-media.bluesky"), + // // icon: "/assets/images/logo-bluesky.svg", + // icon: BlueskyLogo, + // }, + mastodon: { + href: "https://fedihum.org/@acdhch_oeaw", + label: t("social-media.mastodon"), + // icon: "/assets/images/logo-mastodon.svg", + icon: MastodonLogo, + }, + twitter: { + href: "https://www.twitter.com/acdh_oeaw", + label: t("social-media.twitter"), + // icon: "/assets/images/logo-twitter.svg", + icon: TwitterLogo, + }, + youtube: { + href: "https://www.youtube.com/channel/UCgaEMaMbPkULYRI5u6gvG-w", + label: t("social-media.youtube"), + // icon: "/assets/images/logo-youtube.svg", + icon: YouTubeLogo, + }, + } satisfies Record; + + const acdhLinks = { + de: { href: "https://www.oeaw.ac.at/de/acdh/" }, + en: { href: "https://www.oeaw.ac.at/acdh/" }, + } satisfies Record; + + return ( +
+
+ + + +
+ +
+ + + + © {new Date().getUTCFullYear()}{" "} + + Austrian Centre for Digital Humanities and Cultural Heritage + + +
+
+ ); +} diff --git a/app/[locale]/_components/app-header.tsx b/app/[locale]/_components/app-header.tsx new file mode 100644 index 0000000..3efc052 --- /dev/null +++ b/app/[locale]/_components/app-header.tsx @@ -0,0 +1,50 @@ +import { useTranslations } from "next-intl"; +import type { ReactNode } from "react"; + +import { + AppNavigation, + AppNavigationMobile, + type NavigationItem, +} from "@/app/[locale]/_components/app-navigation"; +import { ColorSchemeSwitcher } from "@/app/[locale]/_components/color-scheme-switcher"; +import { LocaleSwitcher } from "@/app/[locale]/_components/locale-switcher"; +import { createHref } from "@/lib/create-href"; + +export function AppHeader(): ReactNode { + const t = useTranslations("AppHeader"); + + const label = t("navigation-primary"); + + const navigation = { + home: { + type: "link", + href: createHref({ pathname: "/" }), + label: t("links.home"), + }, + about: { + type: "link", + href: createHref({ pathname: "/about" }), + label: t("links.about"), + }, + } satisfies Record; + + return ( +
+
+ + + +
+ + +
+
+
+ ); +} diff --git a/app/[locale]/_components/app-layout.tsx b/app/[locale]/_components/app-layout.tsx new file mode 100644 index 0000000..db09bba --- /dev/null +++ b/app/[locale]/_components/app-layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +interface AppLayoutProps { + children: ReactNode; +} + +export function AppLayout(props: Readonly): ReactNode { + const { children } = props; + + return ( +
{children}
+ ); +} diff --git a/app/[locale]/_components/app-navigation.tsx b/app/[locale]/_components/app-navigation.tsx new file mode 100644 index 0000000..7599f93 --- /dev/null +++ b/app/[locale]/_components/app-navigation.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { cn } from "@acdh-oeaw/style-variants"; +import { ChevronDownIcon, ChevronRightIcon, MenuIcon, XIcon } from "lucide-react"; +import { Fragment, type ReactNode } from "react"; +import { + Button, + Dialog, + DialogTrigger, + Disclosure, + DisclosurePanel, + Heading, + Menu, + MenuItem, + MenuTrigger, + Modal, + ModalOverlay, + Popover, + Separator, +} from "react-aria-components"; + +import { Logo } from "@/components/logo"; +import { NavLink, type NavLinkProps } from "@/components/nav-link"; + +interface NavigationLink { + type: "link"; + href: NavLinkProps["href"]; + label: string; +} + +interface NavigationSeparator { + type: "separator"; +} + +interface NavigationMenu { + type: "menu"; + label: string; + children: Record; +} + +export type NavigationItem = NavigationLink | NavigationSeparator | NavigationMenu; + +interface AppNavigationProps { + label: string; + navigation: { home: NavigationLink } & Record; +} + +export function AppNavigation(props: AppNavigationProps): ReactNode { + const { label, navigation } = props; + + return ( + + ); +} + +interface AppNavigationMobileProps { + label: string; + menuCloseLabel: string; + menuOpenLabel: string; + menuTitleLabel: string; + navigation: Record; +} + +export function AppNavigationMobile(props: AppNavigationMobileProps): ReactNode { + const { label, menuCloseLabel, menuOpenLabel, menuTitleLabel, navigation } = props; + + return ( + + + + + + {({ close }) => { + return ( + +
+ + {menuTitleLabel} + + +
+
    + {Object.entries(navigation).map(([id, item]) => { + switch (item.type) { + case "link": { + return ( +
  • + + {item.label} + +
  • + ); + } + + case "separator": { + return ( + + ); + } + + case "menu": { + return ( +
  • + + + + + +
      + {Object.entries(item.children).map(([id, item]) => { + switch (item.type) { + case "link": { + return ( +
    • + + {item.label} + +
    • + ); + } + + case "separator": { + return ( + + ); + } + } + })} +
    +
    +
    +
  • + ); + } + } + })} +
+
+ ); + }} +
+
+
+
+ ); +} diff --git a/app/[locale]/_components/color-scheme-select-loader.tsx b/app/[locale]/_components/color-scheme-select-loader.tsx new file mode 100644 index 0000000..6788e30 --- /dev/null +++ b/app/[locale]/_components/color-scheme-select-loader.tsx @@ -0,0 +1,18 @@ +"use client"; + +import dynamic from "next/dynamic"; + +import { ColorSchemeSelectLoadingIndicator } from "@/app/[locale]/_components/color-scheme-select-loading-indicator"; + +export const ColorSchemeSelect = dynamic( + () => { + return import("@/app/[locale]/_components/color-scheme-select").then((module) => { + return { default: module.ColorSchemeSelect }; + }); + }, + { + // @ts-expect-error `ReactNode` is a valid return type. + loading: ColorSchemeSelectLoadingIndicator, + ssr: false, + }, +); diff --git a/app/[locale]/_components/color-scheme-select-loading-indicator.tsx b/app/[locale]/_components/color-scheme-select-loading-indicator.tsx new file mode 100644 index 0000000..aaaed84 --- /dev/null +++ b/app/[locale]/_components/color-scheme-select-loading-indicator.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export function ColorSchemeSelectLoadingIndicator(): ReactNode { + return
; +} diff --git a/app/[locale]/_components/color-scheme-select.tsx b/app/[locale]/_components/color-scheme-select.tsx new file mode 100644 index 0000000..4c64564 --- /dev/null +++ b/app/[locale]/_components/color-scheme-select.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { cn } from "@acdh-oeaw/style-variants"; +import { LaptopIcon, MoonIcon, SunIcon } from "lucide-react"; +import type { Key, ReactNode } from "react"; +import { Button, ListBox, ListBoxItem, Popover, Select, SelectValue } from "react-aria-components"; + +import type { ColorScheme } from "@/lib/color-scheme-script"; +import { useColorScheme } from "@/lib/use-color-scheme"; + +interface ColorSchemeSelectProps { + items: Record; + label: string; +} + +export function ColorSchemeSelect(props: Readonly): ReactNode { + const { items, label } = props; + + const { colorSchemeState, setColorScheme } = useColorScheme(); + + function onSelectionChange(key: Key) { + const value = key as keyof ColorSchemeSelectProps["items"]; + + setColorScheme(value === "system" ? null : value); + } + + const selectedKey = colorSchemeState.kind === "system" ? "system" : colorSchemeState.colorScheme; + + const icons = { + dark: MoonIcon, + light: SunIcon, + system: LaptopIcon, + }; + + const Icon = icons[colorSchemeState.colorScheme]; + + return ( + + ); +} diff --git a/app/[locale]/_components/color-scheme-switcher.tsx b/app/[locale]/_components/color-scheme-switcher.tsx new file mode 100644 index 0000000..d3f1431 --- /dev/null +++ b/app/[locale]/_components/color-scheme-switcher.tsx @@ -0,0 +1,19 @@ +import { useTranslations } from "next-intl"; +import { type ReactNode, useMemo } from "react"; + +import { ColorSchemeSelect } from "@/app/[locale]/_components/color-scheme-select-loader"; +import type { ColorScheme } from "@/lib/color-scheme-script"; + +export function ColorSchemeSwitcher(): ReactNode { + const t = useTranslations("ColorSchemeSwitcher"); + + const items = useMemo(() => { + return Object.fromEntries( + (["system", "light", "dark"] as const).map((id) => { + return [id, t(`color-schemes.${id}`)]; + }), + ) as Record; + }, [t]); + + return ; +} diff --git a/app/[locale]/_components/locale-switcher-link.tsx b/app/[locale]/_components/locale-switcher-link.tsx new file mode 100644 index 0000000..b4f5680 --- /dev/null +++ b/app/[locale]/_components/locale-switcher-link.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import type { ReactNode } from "react"; + +import { Link } from "@/components/link"; +import type { Locale } from "@/config/i18n.config"; +import { createHref } from "@/lib/create-href"; +import { usePathname } from "@/lib/i18n/navigation"; + +interface LocaleSwitcherLinkProps { + children: ReactNode; + locale: Locale; +} + +export function LocaleSwitcherLink(props: Readonly): ReactNode { + const { children, locale } = props; + + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return ( + + {children} + + ); +} + +export function LocaleSwitcherLinkFallback(props: Readonly): ReactNode { + const { children, locale } = props; + + const pathname = usePathname(); + + return ( + + {children} + + ); +} diff --git a/app/[locale]/_components/locale-switcher.tsx b/app/[locale]/_components/locale-switcher.tsx new file mode 100644 index 0000000..56cc4d8 --- /dev/null +++ b/app/[locale]/_components/locale-switcher.tsx @@ -0,0 +1,72 @@ +import { useLocale, useTranslations } from "next-intl"; +import { Fragment, type ReactNode, Suspense, useMemo } from "react"; + +import { + LocaleSwitcherLink, + LocaleSwitcherLinkFallback, +} from "@/app/[locale]/_components/locale-switcher-link"; +import { type Locale, locales } from "@/config/i18n.config"; + +export function LocaleSwitcher(): ReactNode { + const currentLocale = useLocale(); + const t = useTranslations("LocaleSwitcher"); + + const items = useMemo(() => { + const displayNames = new Intl.DisplayNames([currentLocale], { type: "language" }); + + return Object.fromEntries( + locales.map((locale) => { + return [locale, displayNames.of(locale)]; + }), + ) as Record; + }, [currentLocale]); + + return ( +
+ {locales.map((locale, index) => { + const label = items[locale]; + + const separator = index !== 0 ? | : null; + + if (locale === currentLocale) { + return ( + + {separator} + + {t("current-locale", { locale: label })} + + {locale.toUpperCase()} + + + ); + } + + const children = ( + + {t("switch-locale-to", { locale: label })} + {locale.toUpperCase()} + + ); + + /** + * Suspense boundary is necessary to avoid client-side deoptimisation caused by `useSearchParams`. + * + * @see https://nextjs.org/docs/messages/deopted-into-client-rendering + */ + return ( + + {separator} + + {children} + } + > + {children} + + + ); + })} +
+ ); +} diff --git a/app/[locale]/_components/providers.tsx b/app/[locale]/_components/providers.tsx new file mode 100644 index 0000000..f6706df --- /dev/null +++ b/app/[locale]/_components/providers.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { NextIntlClientProvider } from "next-intl"; +import type { ReactNode } from "react"; +import { I18nProvider, RouterProvider } from "react-aria-components"; + +import type { Locale } from "@/config/i18n.config"; +import { useRouter } from "@/lib/i18n/navigation"; + +interface ProvidersProps { + children: ReactNode; + locale: Locale; + messages: Partial; +} + +export function Providers(props: Readonly): ReactNode { + const { children, locale, messages } = props; + + const router = useRouter(); + + return ( + + + {children} + + + ); +} diff --git a/app/[locale]/_components/social-media-logos.tsx b/app/[locale]/_components/social-media-logos.tsx new file mode 100644 index 0000000..d2b8ff3 --- /dev/null +++ b/app/[locale]/_components/social-media-logos.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; + +interface BlueskyLogoProps { + className?: string; +} + +export function BlueskyLogo(props: BlueskyLogoProps): ReactNode { + const { className } = props; + + return ( + + + + ); +} + +interface MastodonLogoProps { + className?: string; +} + +export function MastodonLogo(props: MastodonLogoProps): ReactNode { + const { className } = props; + + return ( + + + + ); +} + +interface TwitterLogoProps { + className?: string; +} + +export function TwitterLogo(props: TwitterLogoProps): ReactNode { + const { className } = props; + + return ( + + + + ); +} + +interface YouTubeLogoProps { + className?: string; +} + +export function YouTubeLogo(props: YouTubeLogoProps): ReactNode { + const { className } = props; + + return ( + + + + ); +} diff --git a/app/[locale]/_components/tailwind-indicator.tsx b/app/[locale]/_components/tailwind-indicator.tsx new file mode 100644 index 0000000..f2c202d --- /dev/null +++ b/app/[locale]/_components/tailwind-indicator.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +import { env } from "@/config/env.config"; + +export function TailwindIndicator(): ReactNode { + if (env.NODE_ENV !== "development") return null; + + return ( +
+ 2xs + xs + sm + md + lg + xl + 2xl + 3xl +
+ ); +} diff --git a/app/[locale]/about/page.tsx b/app/[locale]/about/page.tsx new file mode 100644 index 0000000..dead758 --- /dev/null +++ b/app/[locale]/about/page.tsx @@ -0,0 +1,159 @@ +import type { Metadata, ResolvingMetadata } from "next"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; + +import { Image } from "@/components/image"; +import { MainContent } from "@/components/main-content"; +import type { Locale } from "@/config/i18n.config"; + +interface AboutPageProps { + params: { + locale: Locale; + }; +} + +export async function generateMetadata( + props: Readonly, + _parent: ResolvingMetadata, +): Promise { + const { params } = props; + + const { locale } = params; + + const t = await getTranslations({ locale, namespace: "AboutPage" }); + + const metadata: Metadata = { + title: t("meta.title"), + }; + + return metadata; +} + +export default async function AboutPage(props: Readonly): Promise { + const { params } = props; + + const { locale } = params; + + setRequestLocale(locale); + + const t = await getTranslations("AboutPage"); + + return ( + +
+

+ {t("title")} +

+

+ Veniam adipisicing ut consectetur do esse. Non consequat pariatur eiusmod dolor aliquip + officia voluptate ut aliquip enim anim duis dolore. Labore aute magna officia ullamco + adipisicing aute laboris sunt nulla voluptate adipisicing non. +

+
+ +
+

Lorem Ipsum Dolor Sit Amet

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque vehicula metus nec erat + facilisis, a dapibus nisl lacinia. Praesent fermentum, risus in fermentum faucibus, neque + lorem scelerisque ipsum, id convallis libero justo nec + lorem. +

+
+ +
Mollit officia aliqua dolore ipsum.
+
+

+ Donec nec magna nec nisi sollicitudin fringilla. Morbi venenatis leo sed odio fermentum, + vitae pulvinar turpis dapibus. Integer ac leo eget orci consectetur venenatis. Duis et + velit sed est aliquam gravida ut sit amet nisi. +

+

Vestibulum Elementum

+

+ Curabitur nec vestibulum felis, id euismod lectus. Mauris at nulla a mi scelerisque + lacinia a ut mi. Sed ac eros non sapien congue mollis non vitae enim. +

+
    +
  • Aliquam tincidunt nunc et dolor tristique, at tincidunt ipsum laoreet.
  • +
  • Praesent pulvinar eros sed turpis tristique, non convallis libero fermentum.
  • +
  • Nam luctus justo non purus suscipit, sed convallis urna dapibus.
  • +
+

+ Quis id nostrud nostrud voluptate in. Enim exercitation mollit mollit exercitation. Est ea + occaecat consequat nisi excepteur. Labore consequat ipsum ex ad aliquip in est officia + magna incididunt eiusmod. Cupidatat consequat in Lorem laborum ipsum nulla deserunt id do + sit ut et. +

+

Phasellus Varius Nibh

+

+ Suspendisse ultricies lectus vel gravida gravida. Phasellus dapibus, arcu nec vestibulum + hendrerit, nisl tortor tempor risus, eget tincidunt arcu orci et sapien. Etiam sed nunc + non lectus aliquet tincidunt. +

+

+ Morbi non mauris at justo vehicula mollis sit amet nec sapien. Integer accumsan arcu at + orci dictum, ac placerat augue hendrerit. Duis sit amet leo ac augue suscipit vulputate. +

+

+ Cillum minim duis occaecat cupidatat dolore irure aliquip in dolor aliquip sit ea. + Consectetur incididunt qui reprehenderit cillum nisi culpa est. Velit in nisi ipsum dolore + reprehenderit dolore cupidatat sunt magna proident cillum proident. Irure velit aliquip + magna et mollit culpa in voluptate sunt sunt consequat laborum sit. +

+

+ Commodo tempor proident consectetur do. Nisi et excepteur ipsum dolor ex consectetur. + Occaecat ut reprehenderit exercitation id laborum proident ut magna aliquip sit ut ex. +

+
    +
  1. + Laborum esse eiusmod in est irure Lorem. Ullamco proident nulla + pariatur eiusmod in reprehenderit. Anim mollit minim sit ea pariatur officia id dolore + aute incididunt consectetur laboris cupidatat. Non ut et ex magna duis occaecat elit non + ea occaecat. Ullamco nisi deserunt et ea nostrud culpa aliquip in non ut enim in magna. + Exercitation aute tempor velit voluptate duis fugiat fugiat. +
  2. +
  3. + + Deserunt elit adipisicing dolore do proident est ex et aute in enim est sit tempor. + + Tempor nulla eu aute dolore. Sunt ex minim elit ea elit aute quis ad dolor incididunt + cillum qui dolor. Enim Lorem ipsum officia enim id occaecat culpa elit nostrud enim ad + occaecat sint non. Aliquip cillum reprehenderit dolore consequat adipisicing. +
  4. +
  5. + Quis excepteur ullamco in sint est. Voluptate incididunt reprehenderit + qui elit aute consequat aute. Ut occaecat dolore culpa dolore in culpa nostrud. Ea esse + consequat aliqua consectetur sunt amet occaecat cillum duis velit velit. Nostrud ex + adipisicing laboris mollit ex qui et duis exercitation id. +
  6. +
  7. + Nulla culpa dolor amet eu laborum do voluptate et laboris. Aute amet + dolor et fugiat. Lorem tempor incididunt minim excepteur consequat velit. Proident + excepteur dolor sunt ea. Elit aliqua nisi velit eu occaecat. +
  8. +
+

+ Aute cillum sunt adipisicing in eu. Deserunt ad veniam elit sunt consequat ex laborum ad + nulla amet. Ipsum commodo sint excepteur elit magna nisi nostrud id. Mollit veniam laborum + ut Lorem amet laboris ut quis. +

+

+ Do nisi mollit reprehenderit esse commodo incididunt commodo Lorem nisi est consectetur in + ullamco Lorem. Reprehenderit non culpa officia irure tempor consequat sunt excepteur. Id + anim minim aute aliqua dolor eiusmod est eu anim dolor ullamco. Mollit dolore cupidatat + aute quis ad dolore magna labore velit ea officia amet laboris esse. Labore elit cupidatat + laboris exercitation Lorem cupidatat labore commodo labore. Irure ullamco sint non tempor + ex mollit proident sunt velit laboris. +

+
+
+ ); +} 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..3a03340 --- /dev/null +++ b/app/[locale]/imprint/page.tsx @@ -0,0 +1,68 @@ +import { HttpError, request } from "@acdh-oeaw/lib"; +import type { Metadata, ResolvingMetadata } from "next"; +import { notFound } from "next/navigation"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; + +import { MainContent } from "@/components/main-content"; +import type { Locale } from "@/config/i18n.config"; +import { createImprintUrl } from "@/config/imprint.config"; + +interface ImprintPageProps { + params: { + locale: Locale; + }; +} + +export async function generateMetadata( + props: Readonly, + _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 async function ImprintPage(props: Readonly): Promise { + const { params } = props; + + const { locale } = params; + setRequestLocale(locale); + + const t = await getTranslations("ImprintPage"); + + const html = await getImprintHtml(locale); + + return ( + +
+

+ {t("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..5fcb001 --- /dev/null +++ b/app/[locale]/internal-error.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { cn } from "@acdh-oeaw/style-variants"; +import { useTranslations } from "next-intl"; +import { type ReactNode, useEffect, useTransition } from "react"; +import { Button } from "react-aria-components"; + +import { MainContent } from "@/components/main-content"; +import { useRouter } from "@/lib/i18n/navigation"; + +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: Readonly): ReactNode { + const { error, reset } = props; + + const t = useTranslations("Error"); + + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + 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..a36555c --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,144 @@ +import { pick } from "@acdh-oeaw/lib"; +import { cn } from "@acdh-oeaw/style-variants"; +import type { Metadata, ResolvingMetadata } from "next"; +import { notFound } from "next/navigation"; +import { getMessages, getTranslations, 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 { AppFooter } from "@/app/[locale]/_components/app-footer"; +import { AppHeader } from "@/app/[locale]/_components/app-header"; +import { AppLayout } from "@/app/[locale]/_components/app-layout"; +import { Providers } from "@/app/[locale]/_components/providers"; +import { TailwindIndicator } from "@/app/[locale]/_components/tailwind-indicator"; +import { id } from "@/components/main-content"; +import { SkipLink } from "@/components/skip-link"; +import { env } from "@/config/env.config"; +import { isValidLocale, 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 { getToastMessage } from "@/lib/i18n/redirect-with-message"; + +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, "children">, + _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 async function LocaleLayout(props: Readonly): Promise { + const { children, params } = props; + + const { locale } = params; + if (!isValidLocale(locale)) { + return notFound(); + } + setRequestLocale(locale); + + const t = await getTranslations("LocaleLayout"); + const meta = await getTranslations("metadata"); + const messages = (await getMessages()) as IntlMessages; + const errorPageMessages = pick(messages, ["Error"]); + + // TODO: + const _toastMessage = await getToastMessage(); + + return ( + + + {/* @see https://nextjs.org/docs/app/building-your-application/optimizing/metadata#json-ld */} +