diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 000000000..edc4a8907 --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,11 @@ +version = 1 + +[[analyzers]] +name = "javascript" + + [analyzers.meta] + plugins = ["react"] + environment = ["nodejs"] + +[[transformers]] +name = "prettier" \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..be4892035 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git +dev \ No newline at end of file diff --git a/.env.example b/.env.example index e27b97f5f..dc82ea23a 100644 --- a/.env.example +++ b/.env.example @@ -4,12 +4,30 @@ # This file will be committed to version control, so make sure not to have any secrets in it. # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. -# The database URL is used to connect to your PlanetScale database. +# This is how you can use the sqlite driver: +DB_DRIVER='better-sqlite3' DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' +# Those are the two ways to use the mysql2 driver: +# 1. Using the URL format: +# DB_DRIVER='mysql2' +# DB_URL='mysql://user:password@host:port/database' +# 2. Using the connection options format: +# DB_DRIVER='mysql2' +# DB_HOST='localhost' +# DB_PORT='3306' +# DB_USER='username' +# DB_PASSWORD='password' +# DB_NAME='name-of-database' + # @see https://next-auth.js.org/configuration/options#nextauth_url AUTH_URL='http://localhost:3000' # You can generate the secret via 'openssl rand -base64 32' on Unix # @see https://next-auth.js.org/configuration/options#secret AUTH_SECRET='supersecret' + +TURBO_TELEMETRY_DISABLED=1 + +# Configure logging to use winston logger +NODE_OPTIONS='-r @homarr/log/override' \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 043f0f9bc..2c9aab231 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ # These are supported funding model platforms -github: juliusmarminge +open_collective: homarr diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index ae1ccf2da..c2be403e0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -26,4 +26,3 @@ body: attributes: label: Additional information description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. - diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..b551590ab --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +
+
+ +

Homarr

+
+ +**Thank you for your contribution. Please ensure that your pull request meets the following pull request:** + +- [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``) +- [ ] Pull request targets ``dev`` branch +- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) +- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation) + diff --git a/.github/renovate.json b/.github/renovate.json index 0942ac01d..1f5acc772 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,17 +1,15 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:base" - ], + "extends": ["config:recommended"], "packageRules": [ { - "matchPackagePatterns": [ - "^@homarr/" - ], + "matchPackagePatterns": ["^@homarr/"], "enabled": false } ], "updateInternalDeps": true, "rangeStrategy": "bump", - "automerge": true -} \ No newline at end of file + "automerge": false, + "baseBranches": ["dev"], + "dependencyDashboard": false +} diff --git a/.github/workflows/automatic-release.yml b/.github/workflows/automatic-release.yml deleted file mode 100644 index 8489bfce4..000000000 --- a/.github/workflows/automatic-release.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Automatic Release - -on: - schedule: - - cron: 0 20 * * 5 # At 20:00 on Friday. - https://crontab.guru/#0_20_*_*_5 - workflow_dispatch: - -env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_AUTOMATIC_RELEASE }} - -jobs: - merge: - runs-on: ubuntu-latest - steps: - - name: Discord notification - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - uses: Ilshidur/action-discord@master - with: - args: 'Preparing the automatic release...' - - uses: actions/checkout@v4 - - uses: peter-evans/create-pull-request@v5 - id: create-pull-request - with: - base: main - branch: dev - delete-branch: false - title: "(chore): version update" - reviewers: manuel-rw, meierschlumpf - - name: Check outputs - if: ${{ steps.create-pull-request.outputs.pull-request-number }} - run: | - echo "Pull Request Number - ${{ steps.create-pull-request.outputs.pull-request-number }}" - echo "Pull Request URL - ${{ steps.create-pull-request.outputs.pull-request-url }}" - - name: Discord notification - if: ${{ steps.create-pull-request.outputs.pull-request-number }} - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - uses: Ilshidur/action-discord@master - with: - args: 'Deployment pull request has been created at [${{ steps.create-pull-request.outputs.pull-request-number }}](${{ steps.create-pull-request.outputs.pull-request-url }})' \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..b8fbfaa9d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,31 @@ +name: Build apps and migration script + +on: + pull_request: + branches: ["*"] + push: + branches: ["main"] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +env: + FORCE_COLOR: 3 + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup + uses: ./tooling/github/setup + + - name: Copy env + shell: bash + run: cp .env.example .env + + - name: Build + run: pnpm build diff --git a/.github/workflows/ci.yml b/.github/workflows/code-quality.yml similarity index 84% rename from .github/workflows/ci.yml rename to .github/workflows/code-quality.yml index cdd48bb9c..44846a959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/code-quality.yml @@ -1,4 +1,4 @@ -name: CI +name: Code quality analysis on: pull_request: @@ -55,3 +55,14 @@ jobs: - name: Typecheck run: turbo typecheck + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup + uses: ./tooling/github/setup + + - name: Test + run: pnpm test diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000..94bfba67a --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,89 @@ +name: Docker image + +on: + pull_request: + types: + - closed + branches: + - main + workflow_dispatch: {} + +permissions: + contents: write + packages: write + +env: + TURBO_TELEMETRY_DISABLED: 1 + +concurrency: production + +jobs: + deploy: + name: Deploy docker image + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20] + steps: + - name: Discord notification + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@master + with: + args: "Deployment of an image has been triggered" + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v2 + with: + version: 8 + - 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 + - name: Build artifacts + run: pnpm build + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Bump version and push tag + id: githubTagAction + uses: anothrNick/github-tag-action@1.69.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WITH_V: false + DRY_RUN: true + - name: Discord notification + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@master + with: + args: "Image has been tagged as ${{ steps.githubTagAction.outputs.new_tag }}" + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=raw,value=${{ steps.githubTagAction.outputs.new_tag }} + - name: Build and push + id: buildPushAction + uses: docker/build-push-action@v5 + with: + platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6 + context: . + push: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + network: host + - name: Discord notification + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@master + with: + args: "Image built with ID ${{ steps.buildPushAction.outputs.imageid }}" diff --git a/.gitignore b/.gitignore index a9669defa..8327c8b84 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules .pnp .pnp.js +.idea/ # testing coverage @@ -13,6 +14,9 @@ coverage out/ next-env.d.ts +# nest.js +apps/nestjs/dist + # nitro .nitro/ .output/ @@ -44,4 +48,10 @@ yarn-error.log* .turbo # database -db.sqlite \ No newline at end of file +db.sqlite + +# logs +*.log + +apps/tasks/tasks.cjs +apps/websocket/wssServer.cjs \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 87ec8842b..f203ab89b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.18.2 +20.13.1 diff --git a/.vscode/launch.json b/.vscode/launch.json index 5fcd84524..b24d8b507 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,12 +2,86 @@ "version": "0.2.0", "configurations": [ { - "name": "Next.js", + "name": "dev", "type": "node-terminal", "request": "launch", "command": "pnpm dev", - "cwd": "${workspaceFolder}/apps/nextjs/", + "cwd": "${workspaceFolder}", "skipFiles": ["/**"] + }, + { + "name": "docker dev", + "type": "node-terminal", + "request": "launch", + "command": "pnpm docker:dev", + "cwd": "${workspaceFolder}", + "skipFiles": [ + "/**" + ] + }, + { + "name": "lint fix", + "type": "node-terminal", + "request": "launch", + "command": "pnpm lint:fix", + "cwd": "${workspaceFolder}", + "skipFiles": [ + "/**" + ] + }, + { + "name": "format fix", + "type": "node-terminal", + "request": "launch", + "command": "pnpm format:fix", + "cwd": "${workspaceFolder}", + "skipFiles": [ + "/**" + ] + }, + { + "name": "db push", + "type": "node-terminal", + "request": "launch", + "command": "pnpm db:push", + "cwd": "${workspaceFolder}", + "skipFiles": ["/**"] + }, + { + "name": "db studio", + "type": "node-terminal", + "request": "launch", + "command": "pnpm db:studio", + "cwd": "${workspaceFolder}", + "skipFiles": ["/**"] + }, + { + "name": "db migration run", + "type": "node-terminal", + "request": "launch", + "command": "pnpm db:migration:run", + "cwd": "${workspaceFolder}", + "skipFiles": ["/**"] + }, + { + "name": "test", + "type": "node-terminal", + "request": "launch", + "command": "pnpm test", + "cwd": "${workspaceFolder}", + "skipFiles": [ + "/**" + ] + }, + { + "name": "test ui", + "type": "node-terminal", + "request": "launch", + "command": "pnpm test:ui", + "cwd": "${workspaceFolder}", + "skipFiles": [ + "/**" + ] } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 44a73ec3a..53c760db0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,13 @@ { "mode": "auto" } + ], + "typescript.tsdk": "node_modules\\typescript\\lib", + "js/ts.implicitProjectConfig.experimentalDecorators": true, + "prettier.configPath": "./tooling/prettier/index.mjs", + "cSpell.words": [ + "superjson", + "homarr", + "trpc" ] -} +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b9471f77e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,87 @@ +FROM node:20.13.1-alpine AS base + +FROM base AS builder +RUN apk add --no-cache libc6-compat +RUN apk update +# Set working directory +WORKDIR /app +COPY . . +RUN npm i -g turbo +RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out +RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out +RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out +RUN turbo prune @homarr/db --docker --out-dir ./migration-out + +# Add lockfile and package.json's of isolated subworkspace +FROM base AS installer +RUN apk add --no-cache libc6-compat curl bash +RUN apk update +WORKDIR /app + +# First install the dependencies (as they change less often) +COPY .gitignore .gitignore + +COPY --from=builder /app/tasks-out/json/ . +COPY --from=builder /app/tasks-out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm && pnpm install + +COPY --from=builder /app/websocket-out/json/ . +COPY --from=builder /app/websocket-out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm && pnpm install + +COPY --from=builder /app/migration-out/json/ . +COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm && pnpm install + +COPY --from=builder /app/next-out/json/ . +COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml + +RUN corepack enable pnpm && pnpm install sharp -w + +# Build the project +COPY --from=builder /app/tasks-out/full/ . +COPY --from=builder /app/websocket-out/full/ . +COPY --from=builder /app/next-out/full/ . +COPY --from=builder /app/migration-out/full/ . +# Copy static data as it is not part of the build +COPY static-data ./static-data +ARG SKIP_ENV_VALIDATION=true +RUN corepack enable pnpm && pnpm turbo run build + +FROM base AS runner +WORKDIR /app + +RUN apk add --no-cache redis +RUN mkdir /appdata +RUN mkdir /appdata/db +RUN mkdir /appdata/redis +VOLUME /appdata + +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +RUN chown -R nextjs:nodejs /appdata +USER nextjs + +COPY --from=installer /app/apps/nextjs/next.config.mjs . +COPY --from=installer /app/apps/nextjs/package.json . + +COPY --from=installer --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs +COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs +COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node + +COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./ +COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static +COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public +COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh +COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf + +ENV DB_URL='/appdata/db/db.sqlite' +ENV DB_DIALECT='sqlite' +ENV DB_DRIVER='better-sqlite3' + +CMD ["sh", "run.sh"] diff --git a/README.md b/README.md index a635a2b10..2eb9f8222 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,5 @@ -## Quick Start +# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE. +## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS +### EVERYTHING IS SUBJECT TO CHANGE -To get it running, follow the steps below: - -### 1. Setup dependencies - -```bash -# Install dependencies -pnpm i - -# Configure environment variables -# There is an `.env.example` in the root directory you can use for reference -cp .env.example .env - -# Push the Drizzle schema to the database -pnpm db:push -``` - -### 2. Start application - -Run `pnpm dev` at the project root folder to start the application. - -> **Note** -> The authentication will currently fail with the message `TypeError: Failed to construct 'URL': Invalid base URL`. This issue will be resolved in the next next-auth beta release. You can track the issue [here](https://github.com/nextauthjs/next-auth/issues/9279). - -You can find the initial account creation page at [http://localhost:3000/init/user](http://localhost:3000/init/user). -After that you can login at [http://localhost:3000/auth/login](http://localhost:3000/auth/login). - -### 3. When it's time to add a new package - -To add a new package, simply run `pnpm turbo gen init` in the monorepo root. This will prompt you for a package name as well as if you want to install any dependencies to the new package (of course you can also do this yourself later). - -The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary configurations for tooling around your package such as formatting, linting and typechecking. When the package is created, you're ready to go build out the package. - -## References - -The stack originates from [create-t3-app](https://github.com/t3-oss/create-t3-app). - -A [blog post](https://jumr.dev/blog/t3-turbo) where I wrote how to migrate a T3 app into this. +Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it diff --git a/apps/nextjs/next.config.mjs b/apps/nextjs/next.config.mjs index d48c97617..fcf1321a4 100644 --- a/apps/nextjs/next.config.mjs +++ b/apps/nextjs/next.config.mjs @@ -4,31 +4,15 @@ import "@homarr/auth/env.mjs"; /** @type {import("next").NextConfig} */ const config = { + output: "standalone", reactStrictMode: true, - /** Enables hot reloading for local packages without a build step */ - transpilePackages: [ - "@homarr/api", - "@homarr/auth", - "@homarr/db", - "@homarr/ui", - "@homarr/validation", - "@homarr/form", - "@homarr/notifications", - "@homarr/spotlight", - ], /** We already do linting and typechecking as separate tasks in CI */ eslint: { ignoreDuringBuilds: true }, typescript: { ignoreBuildErrors: true }, experimental: { - optimizePackageImports: [ - "@mantine/core", - "@mantine/hooks", - "@mantine/dates", - "@mantine/notifications", - "@mantine/form", - "@mantine/spotlight", - ], + optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], }, + transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], images: { domains: ["cdn.jsdelivr.net"], }, diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index d7f597354..5c78e7643 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -2,6 +2,7 @@ "name": "@homarr/nextjs", "version": "0.1.0", "private": true, + "type": "module", "scripts": { "build": "pnpm with-env next build", "clean": "git clean -xdf .next .turbo node_modules", @@ -19,46 +20,58 @@ "@homarr/db": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", + "@homarr/gridstack": "^1.0.0", + "@homarr/log": "workspace:^", + "@homarr/modals": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", "@homarr/spotlight": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0", - "@mantine/hooks": "^7.4.0", - "@mantine/modals": "^7.4.0", - "@mantine/tiptap": "^7.4.0", - "@t3-oss/env-nextjs": "^0.7.1", - "@tanstack/react-query": "^5.17.1", - "@tanstack/react-query-devtools": "^5.17.1", - "@tanstack/react-query-next-experimental": "5.17.1", - "@tiptap/extension-link": "^2.1.13", - "@tiptap/react": "^2.1.13", - "@tiptap/starter-kit": "^2.1.13", - "@trpc/client": "next", + "@mantine/colors-generator": "^7.9.2", + "@mantine/hooks": "^7.9.2", + "@mantine/modals": "^7.9.2", + "@mantine/tiptap": "^7.9.2", + "@homarr/server-settings": "workspace:^0.1.0", + "@t3-oss/env-nextjs": "^0.10.1", + "@tanstack/react-query": "^5.37.1", + "@tanstack/react-query-devtools": "^5.37.1", + "@tanstack/react-query-next-experimental": "5.37.1", + "@trpc/client": "11.0.0-rc.374", "@trpc/next": "next", "@trpc/react-query": "next", "@trpc/server": "next", - "dayjs": "^1.11.10", - "jotai": "^2.6.1", - "mantine-modal-manager": "^7.4.0", - "next": "^14.0.4", - "postcss-preset-mantine": "^1.12.3", - "react": "18.2.0", - "react-dom": "18.2.0", - "superjson": "2.2.1" + "@xterm/addon-canvas": "^0.7.0", + "@xterm/addon-fit": "0.10.0", + "@xterm/xterm": "^5.5.0", + "chroma-js": "^2.4.2", + "dayjs": "^1.11.11", + "dotenv": "^16.4.5", + "flag-icons": "^7.2.2", + "glob": "^10.3.15", + "jotai": "^2.8.1", + "next": "^14.2.3", + "postcss-preset-mantine": "^1.15.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "sass": "^1.77.2", + "superjson": "2.2.1", + "use-deep-compare-effect": "^1.8.1" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", - "@types/node": "^18.18.13", - "@types/react": "^18.2.46", - "@types/react-dom": "^18.2.18", - "dotenv-cli": "^7.3.0", - "eslint": "^8.56.0", - "prettier": "^3.1.0", - "typescript": "^5.3.3" + "@types/chroma-js": "2.4.4", + "@types/node": "^20.12.12", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.3.0", + "concurrently": "^8.2.2", + "eslint": "^8.57.0", + "prettier": "^3.2.5", + "tsx": "4.10.5", + "typescript": "^5.4.5" }, "eslintConfig": { "root": true, diff --git a/apps/nextjs/public/favicon.ico b/apps/nextjs/public/favicon.ico index f0058b404..fe62a6143 100644 Binary files a/apps/nextjs/public/favicon.ico and b/apps/nextjs/public/favicon.ico differ diff --git a/apps/nextjs/public/logo/homarr.png b/apps/nextjs/public/logo/homarr.png deleted file mode 100644 index b28d4b139..000000000 Binary files a/apps/nextjs/public/logo/homarr.png and /dev/null differ diff --git a/apps/nextjs/public/logo/logo.png b/apps/nextjs/public/logo/logo.png new file mode 100644 index 000000000..25581ea5c Binary files /dev/null and b/apps/nextjs/public/logo/logo.png differ diff --git a/apps/nextjs/public/t3-icon.svg b/apps/nextjs/public/t3-icon.svg deleted file mode 100644 index e377165f6..000000000 --- a/apps/nextjs/public/t3-icon.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-accordion.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-accordion.tsx deleted file mode 100644 index 389bed980..000000000 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-accordion.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import type { PropsWithChildren } from "react"; -import { useRouter } from "next/navigation"; - -import type { IntegrationKind } from "@homarr/definitions"; -import { Accordion } from "@homarr/ui"; - -type IntegrationGroupAccordionControlProps = PropsWithChildren<{ - activeTab: IntegrationKind | undefined; -}>; - -export const IntegrationGroupAccordion = ({ - children, - activeTab, -}: IntegrationGroupAccordionControlProps) => { - const router = useRouter(); - - return ( - - tab - ? router.replace(`?tab=${tab}`, {}) - : router.replace("/integrations") - } - > - {children} - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-secret-icons.ts deleted file mode 100644 index f0d35442f..000000000 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-secret-icons.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { IntegrationSecretKind } from "@homarr/definitions"; -import type { TablerIconsProps } from "@homarr/ui"; -import { IconKey, IconPassword, IconUser } from "@homarr/ui"; - -export const integrationSecretIcons = { - username: IconUser, - apiKey: IconKey, - password: IconPassword, -} satisfies Record< - IntegrationSecretKind, - (props: TablerIconsProps) => JSX.Element ->; diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx deleted file mode 100644 index 706cc3df1..000000000 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; - -import type { IntegrationKind } from "@homarr/definitions"; -import { getSecretKinds } from "@homarr/definitions"; -import { useForm, zodResolver } from "@homarr/form"; -import { - showErrorNotification, - showSuccessNotification, -} from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui"; -import type { z } from "@homarr/validation"; -import { validation } from "@homarr/validation"; - -import { api } from "~/trpc/react"; -import { IntegrationSecretInput } from "../_integration-secret-inputs"; -import { - TestConnection, - TestConnectionNoticeAlert, - useTestConnectionDirty, -} from "../_integration-test-connection"; -import { revalidatePathAction } from "../../../../revalidatePathAction"; - -interface NewIntegrationFormProps { - searchParams: Partial> & { - kind: IntegrationKind; - }; -} - -export const NewIntegrationForm = ({ - searchParams, -}: NewIntegrationFormProps) => { - const t = useI18n(); - const secretKinds = getSecretKinds(searchParams.kind); - const initialFormValues = { - name: searchParams.name ?? "", - url: searchParams.url ?? "", - secrets: secretKinds.map((kind) => ({ - kind, - value: "", - })), - }; - const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({ - defaultDirty: true, - initialFormValue: initialFormValues, - }); - const router = useRouter(); - const form = useForm({ - initialValues: initialFormValues, - validate: zodResolver(validation.integration.create.omit({ kind: true })), - onValuesChange, - }); - const { mutateAsync, isPending } = api.integration.create.useMutation(); - - const handleSubmit = async (values: FormType) => { - if (isDirty) return; - await mutateAsync( - { - kind: searchParams.kind, - ...values, - }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("integration.page.create.notification.success.title"), - message: t("integration.page.create.notification.success.message"), - }); - void revalidatePathAction("/integrations").then(() => - router.push("/integrations"), - ); - }, - onError: () => { - showErrorNotification({ - title: t("integration.page.create.notification.error.title"), - message: t("integration.page.create.notification.error.message"), - }); - }, - }, - ); - }; - - return ( -
void handleSubmit(value))}> - - - - - - - -
- - {secretKinds.map((kind, index) => ( - - ))} - -
- - - - - - - - - -
-
- ); -}; - -type FormType = Omit, "kind">; diff --git a/apps/nextjs/src/app/[locale]/(main)/layout.tsx b/apps/nextjs/src/app/[locale]/(main)/layout.tsx index 06e6f91fb..ba273b157 100644 --- a/apps/nextjs/src/app/[locale]/(main)/layout.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/layout.tsx @@ -1,6 +1,5 @@ import type { PropsWithChildren } from "react"; - -import { AppShellMain } from "@homarr/ui"; +import { AppShellMain } from "@mantine/core"; import { MainHeader } from "~/components/layout/header"; import { ClientShell } from "~/components/layout/shell"; diff --git a/apps/nextjs/src/app/[locale]/(main)/page.tsx b/apps/nextjs/src/app/[locale]/(main)/page.tsx index 884e79fd8..26c718534 100644 --- a/apps/nextjs/src/app/[locale]/(main)/page.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/page.tsx @@ -1,4 +1,4 @@ -import { Stack, Title } from "@homarr/ui"; +import { Stack, Title } from "@mantine/core"; export default function HomePage() { return ( diff --git a/apps/nextjs/src/app/[locale]/_client-providers/jotai.tsx b/apps/nextjs/src/app/[locale]/_client-providers/jotai.tsx new file mode 100644 index 000000000..908c05046 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/jotai.tsx @@ -0,0 +1,8 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { Provider } from "jotai"; + +export const JotaiProvider = ({ children }: PropsWithChildren) => { + return {children}; +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/modals.tsx b/apps/nextjs/src/app/[locale]/_client-providers/modals.tsx deleted file mode 100644 index c5f8ca826..000000000 --- a/apps/nextjs/src/app/[locale]/_client-providers/modals.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import type { PropsWithChildren } from "react"; - -import { useScopedI18n } from "@homarr/translation/client"; - -import { ModalsManager } from "../modals"; - -export const ModalsProvider = ({ children }: PropsWithChildren) => { - const t = useScopedI18n("common.action"); - return ( - - {children} - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx b/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx index 626296d69..8297a73bb 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx @@ -3,10 +3,7 @@ import type { PropsWithChildren } from "react"; import { defaultLocale } from "@homarr/translation"; import { I18nProviderClient } from "@homarr/translation/client"; -export const NextInternationalProvider = ({ - children, - locale, -}: PropsWithChildren<{ locale: string }>) => { +export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => { return ( {children} diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session.tsx b/apps/nextjs/src/app/[locale]/_client-providers/session.tsx new file mode 100644 index 000000000..d4e0538db --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/session.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type { PropsWithChildren } from "react"; + +import type { Session } from "@homarr/auth"; +import { SessionProvider } from "@homarr/auth/client"; + +interface AuthProviderProps { + session: Session | null; +} + +export const AuthProvider = ({ children, session }: PropsWithChildren) => { + return {children}; +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 731c027ae..db34ced95 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -1,26 +1,21 @@ "use client"; +import type { PropsWithChildren } from "react"; import { useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; -import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client"; +import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client"; import superjson from "superjson"; -import { env } from "~/env.mjs"; -import { api } from "~/trpc/react"; +import type { AppRouter } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; -const getBaseUrl = () => { - if (typeof window !== "undefined") return ""; // browser should use relative url - if (env.VERCEL_URL) return env.VERCEL_URL; // SSR should use vercel url +const wsClient = createWSClient({ + url: "ws://localhost:3001", +}); - return `http://localhost:${env.PORT}`; // dev SSR should use localhost -}; - -export function TRPCReactProvider(props: { - children: React.ReactNode; - headers?: Headers; -}) { +export function TRPCReactProvider(props: PropsWithChildren) { const [queryClient] = useState( () => new QueryClient({ @@ -32,35 +27,51 @@ export function TRPCReactProvider(props: { }), ); - const [trpcClient] = useState(() => - api.createClient({ - transformer: superjson, + const [trpcClient] = useState(() => { + return clientApi.createClient({ links: [ loggerLink({ enabled: (opts) => - process.env.NODE_ENV === "development" || - (opts.direction === "down" && opts.result instanceof Error), - }), - unstable_httpBatchStreamLink({ - url: `${getBaseUrl()}/api/trpc`, - headers() { - const headers = new Map(props.headers); - headers.set("x-trpc-source", "nextjs-react"); - return Object.fromEntries(headers); - }, + process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), }), + (args) => { + return ({ op, next }) => { + console.log("op", op.type, op.input, op.path, op.id); + if (op.type === "subscription") { + const link = wsLink({ + client: wsClient, + transformer: superjson, + }); + return link(args)({ op, next }); + } + + return unstable_httpBatchStreamLink({ + transformer: superjson, + url: `${getBaseUrl()}/api/trpc`, + headers() { + const headers = new Headers(); + headers.set("x-trpc-source", "nextjs-react"); + return headers; + }, + })(args)({ op, next }); + }; + }, ], - }), - ); + }); + }); return ( - + - - {props.children} - + {props.children} - + ); } + +function getBaseUrl() { + if (typeof window !== "undefined") return window.location.origin; + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; + return `http://localhost:${process.env.PORT ?? 3000}`; +} diff --git a/apps/nextjs/src/app/[locale]/auth/invite/[id]/_registration-form.tsx b/apps/nextjs/src/app/[locale]/auth/invite/[id]/_registration-form.tsx new file mode 100644 index 000000000..b1986f12c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/auth/invite/[id]/_registration-form.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; +import type { z } from "@homarr/validation"; +import { validation } from "@homarr/validation"; + +interface RegistrationFormProps { + invite: { + id: string; + token: string; + }; +} + +export const RegistrationForm = ({ invite }: RegistrationFormProps) => { + const t = useScopedI18n("user"); + const router = useRouter(); + const { mutate, isPending } = clientApi.user.register.useMutation(); + const form = useZodForm(validation.user.registration, { + initialValues: { + username: "", + password: "", + confirmPassword: "", + }, + }); + + const handleSubmit = (values: z.infer) => { + mutate( + { + ...values, + inviteId: invite.id, + token: invite.token, + }, + { + onSuccess() { + showSuccessNotification({ + title: t("action.register.notification.success.title"), + message: t("action.register.notification.success.message"), + }); + router.push("/auth/login"); + }, + onError(error) { + const message = + error.data?.code === "CONFLICT" + ? t("error.usernameTaken") + : t("action.register.notification.error.message"); + + showErrorNotification({ + title: t("action.register.notification.error.title"), + message, + }); + }, + }, + ); + }; + + return ( + +
+ + + + + + + +
+
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx new file mode 100644 index 000000000..989406228 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx @@ -0,0 +1,66 @@ +import { notFound } from "next/navigation"; +import { Card, Center, Stack, Text, Title } from "@mantine/core"; + +import { auth } from "@homarr/auth/next"; +import { and, db, eq } from "@homarr/db"; +import { invites } from "@homarr/db/schema/sqlite"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; +import { RegistrationForm } from "./_registration-form"; + +interface InviteUsagePageProps { + params: { + id: string; + }; + searchParams: { + token: string; + }; +} + +export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) { + const session = await auth(); + if (session) notFound(); + + const invite = await db.query.invites.findFirst({ + where: and(eq(invites.id, params.id), eq(invites.token, searchParams.token)), + columns: { + id: true, + token: true, + expirationDate: true, + }, + with: { + creator: { + columns: { + name: true, + }, + }, + }, + }); + + if (!invite || invite.expirationDate < new Date()) notFound(); + + const t = await getScopedI18n("user.page.invite"); + + return ( +
+ + + + + {t("title")} + + + {t("subtitle")} + + + + + + + {t("description", { username: invite.creator.name })} + + +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx index d0a3f03de..d0345ec72 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx @@ -2,19 +2,13 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; import { signIn } from "@homarr/auth/client"; -import { useForm, zodResolver } from "@homarr/form"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import { - Alert, - Button, - IconAlertTriangle, - PasswordInput, - rem, - Stack, - TextInput, -} from "@homarr/ui"; import type { z } from "@homarr/validation"; import { validation } from "@homarr/validation"; @@ -23,15 +17,14 @@ export const LoginForm = () => { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); - const form = useForm({ - validate: zodResolver(validation.user.signIn), + const form = useZodForm(validation.user.signIn, { initialValues: { name: "", password: "", }, }); - const handleSubmit = async (values: FormType) => { + const handleSubmitAsync = async (values: z.infer) => { setIsLoading(true); setError(undefined); await signIn("credentials", { @@ -40,32 +33,34 @@ export const LoginForm = () => { callbackUrl: "/", }) .then((response) => { - if (!response?.ok) { + if (!response?.ok || response.error) { throw response?.error; } - void router.push("/"); + showSuccessNotification({ + title: t("action.login.notification.success.title"), + message: t("action.login.notification.success.message"), + }); + router.push("/"); }) .catch((error: Error | string) => { setIsLoading(false); setError(error.toString()); + showErrorNotification({ + title: t("action.login.notification.error.title"), + message: t("action.login.notification.error.message"), + }); }); }; return ( -
void handleSubmit(v))}> + void handleSubmitAsync(values))}> - - + +
@@ -78,5 +73,3 @@ export const LoginForm = () => {
); }; - -type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx index e9d8ec451..00de2f2f3 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -1,7 +1,8 @@ +import { Card, Center, Stack, Text, Title } from "@mantine/core"; + import { getScopedI18n } from "@homarr/translation/server"; -import { Card, Center, Stack, Text, Title } from "@homarr/ui"; -import { LogoWithTitle } from "~/components/layout/logo"; +import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { LoginForm } from "./_login-form"; export default async function Login() { @@ -10,7 +11,7 @@ export default async function Login() { return (
- + {t("title")} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts new file mode 100644 index 000000000..2357d7424 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts @@ -0,0 +1,9 @@ +import { api } from "@homarr/api/server"; + +import { createBoardContentPage } from "../_creator"; + +export default createBoardContentPage<{ locale: string }>({ + async getInitialBoardAsync() { + return await api.board.getHomeBoard(); + }, +}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/layout.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/layout.tsx new file mode 100644 index 000000000..7a2eb4b2c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/layout.tsx @@ -0,0 +1,5 @@ +import definition from "./_definition"; + +const { layout } = definition; + +export default layout; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/page.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/page.tsx new file mode 100644 index 000000000..2aacb5b36 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/page.tsx @@ -0,0 +1,7 @@ +import definition from "./_definition"; + +const { generateMetadataAsync: generateMetadata, page } = definition; + +export default page; + +export { generateMetadata }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/_definition.tsx new file mode 100644 index 000000000..82e53110d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/_definition.tsx @@ -0,0 +1,9 @@ +import { api } from "@homarr/api/server"; + +import { createBoardContentPage } from "../_creator"; + +export default createBoardContentPage<{ locale: string; name: string }>({ + async getInitialBoardAsync({ name }) { + return await api.board.getBoardByName({ name }); + }, +}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/layout.tsx new file mode 100644 index 000000000..7a2eb4b2c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/layout.tsx @@ -0,0 +1,5 @@ +import definition from "./_definition"; + +const { layout } = definition; + +export default layout; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/page.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/page.tsx new file mode 100644 index 000000000..2aacb5b36 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/page.tsx @@ -0,0 +1,7 @@ +import definition from "./_definition"; + +const { generateMetadataAsync: generateMetadata, page } = definition; + +export default page; + +export { generateMetadata }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx new file mode 100644 index 000000000..ea80dc68b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { Box, LoadingOverlay, Stack } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; + +import { BoardCategorySection } from "~/components/board/sections/category-section"; +import { BoardEmptySection } from "~/components/board/sections/empty-section"; +import { BoardBackgroundVideo } from "~/components/layout/background"; +import { fullHeightWithoutHeaderAndFooter } from "~/constants"; +import { useIsBoardReady, useRequiredBoard } from "./_context"; + +let boardName: string | null = null; + +export const updateBoardName = (name: string | null) => { + boardName = name; +}; + +type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"]; + +export const useUpdateBoard = () => { + const utils = clientApi.useUtils(); + + const updateBoard = useCallback( + (updaterWithoutUndefined: UpdateCallback) => { + if (!boardName) { + throw new Error("Board name is not set"); + } + utils.board.getBoardByName.setData({ name: boardName }, (previous) => + previous ? updaterWithoutUndefined(previous) : previous, + ); + }, + [utils], + ); + + return { + updateBoard, + }; +}; + +export const ClientBoard = () => { + const board = useRequiredBoard(); + const isReady = useIsBoardReady(); + + const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position); + + const ref = useRef<HTMLDivElement>(null); + + return ( + <Box h="100%" pos="relative"> + <BoardBackgroundVideo /> + <LoadingOverlay + visible={!isReady} + transitionProps={{ duration: 500 }} + loaderProps={{ size: "lg" }} + h={fullHeightWithoutHeaderAndFooter} + /> + <Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}> + {sortedSections.map((section) => + section.kind === "empty" ? ( + <BoardEmptySection key={section.id} section={section} mainRef={ref} /> + ) : ( + <BoardCategorySection key={section.id} section={section} mainRef={ref} /> + ), + )} + </Stack> + </Box> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx new file mode 100644 index 000000000..808759af1 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_context.tsx @@ -0,0 +1,118 @@ +"use client"; + +import type { Dispatch, PropsWithChildren, SetStateAction } from "react"; +import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; + +import { updateBoardName } from "./_client"; + +const BoardContext = createContext<{ + board: RouterOutputs["board"]["getHomeBoard"]; + isReady: boolean; + markAsReady: (id: string) => void; + isEditMode: boolean; + setEditMode: Dispatch<SetStateAction<boolean>>; +} | null>(null); + +export const BoardProvider = ({ + children, + initialBoard, +}: PropsWithChildren<{ + initialBoard: RouterOutputs["board"]["getBoardByName"]; +}>) => { + const pathname = usePathname(); + const utils = clientApi.useUtils(); + const [readySections, setReadySections] = useState<string[]>([]); + const [isEditMode, setEditMode] = useState(false); + const { data } = clientApi.board.getBoardByName.useQuery( + { name: initialBoard.name }, + { + initialData: initialBoard, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + // Update the board name so it can be used within updateBoard method + updateBoardName(initialBoard.name); + + // Invalidate the board when the pathname changes + // This allows to refetch the board when it might have changed - e.g. if someone else added an item + useEffect(() => { + return () => { + setReadySections([]); + void utils.board.getBoardByName.invalidate({ name: initialBoard.name }); + }; + }, [pathname, utils, initialBoard.name]); + + useEffect(() => { + setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.sections.length, setReadySections]); + + const markAsReady = useCallback((id: string) => { + setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id])); + }, []); + + return ( + <BoardContext.Provider + value={{ + board: data, + isReady: data.sections.length === readySections.length, + markAsReady, + isEditMode, + setEditMode, + }} + > + {children} + </BoardContext.Provider> + ); +}; + +export const useMarkSectionAsReady = () => { + const context = useContext(BoardContext); + + if (!context) { + throw new Error("Board is required"); + } + + return context.markAsReady; +}; + +export const useIsBoardReady = () => { + const context = useContext(BoardContext); + + if (!context) { + throw new Error("Board is required"); + } + + return context.isReady; +}; + +export const useRequiredBoard = () => { + const optionalBoard = useOptionalBoard(); + + if (!optionalBoard) { + throw new Error("Board is required"); + } + + return optionalBoard; +}; + +export const useOptionalBoard = () => { + const context = useContext(BoardContext); + + return context?.board; +}; + +export const useEditMode = () => { + const context = useContext(BoardContext); + + if (!context) { + throw new Error("Board is required"); + } + + return [context.isEditMode, context.setEditMode] as const; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx new file mode 100644 index 000000000..fe56c11ea --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import { TRPCError } from "@trpc/server"; + +// Placed here because gridstack styles are used for board content +import "~/styles/gridstack.scss"; + +import { getI18n } from "@homarr/translation/server"; + +import { createMetaTitle } from "~/metadata"; +import { createBoardLayout } from "../_layout-creator"; +import type { Board } from "../_types"; +import { ClientBoard } from "./_client"; +import { BoardContentHeaderActions } from "./_header-actions"; + +export type Params = Record<string, unknown>; + +interface Props<TParams extends Params> { + getInitialBoardAsync: (params: TParams) => Promise<Board>; +} + +export const createBoardContentPage = <TParams extends Record<string, unknown>>({ + getInitialBoardAsync: getInitialBoard, +}: Props<TParams>) => { + return { + layout: createBoardLayout({ + headerActions: <BoardContentHeaderActions />, + getInitialBoardAsync: getInitialBoard, + isBoardContentPage: true, + }), + page: () => { + return <ClientBoard />; + }, + generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => { + try { + const board = await getInitialBoard(params); + const t = await getI18n(); + + return { + title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })), + icons: { + icon: board.faviconImageUrl ? board.faviconImageUrl : undefined, + }, + }; + } catch (error) { + // Ignore not found errors and return empty metadata + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return {}; + } + + throw error; + } + }, + }; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_custom-css.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_custom-css.tsx new file mode 100644 index 000000000..ab01aff39 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_custom-css.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useRequiredBoard } from "./_context"; + +export const CustomCss = () => { + const board = useRequiredBoard(); + + return <style>{board.customCss}</style>; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx new file mode 100644 index 000000000..9222b7d59 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback } from "react"; +import { Group, Menu } from "@mantine/core"; +import { + IconBox, + IconBoxAlignTop, + IconChevronDown, + IconPackageImport, + IconPencil, + IconPencilOff, + IconPlus, + IconSettings, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useModalAction } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; + +import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; +import { ItemSelectModal } from "~/components/board/items/item-select-modal"; +import { useBoardPermissions } from "~/components/board/permissions/client"; +import { useCategoryActions } from "~/components/board/sections/category/category-actions"; +import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; +import { HeaderButton } from "~/components/layout/header/button"; +import { useEditMode, useRequiredBoard } from "./_context"; + +export const BoardContentHeaderActions = () => { + const [isEditMode] = useEditMode(); + const board = useRequiredBoard(); + const { hasChangeAccess } = useBoardPermissions(board); + + if (!hasChangeAccess) { + return null; // Hide actions for user without access + } + + return ( + <> + {isEditMode && <AddMenu />} + + <EditModeMenu /> + + <HeaderButton href={`/boards/${board.name}/settings`}> + <IconSettings stroke={1.5} /> + </HeaderButton> + </> + ); +}; + +const AddMenu = () => { + const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); + const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); + const { addCategoryToEnd } = useCategoryActions(); + const t = useI18n(); + + const handleAddCategory = useCallback( + () => + openCategoryEditModal( + { + category: { + id: "new", + name: "", + }, + onSuccess({ name }) { + addCategoryToEnd({ name }); + }, + submitLabel: t("section.category.create.submit"), + }, + { + title: (t) => t("section.category.create.title"), + }, + ), + [addCategoryToEnd, openCategoryEditModal, t], + ); + + const handleSelectItem = useCallback(() => { + openItemSelectModal(); + }, [openItemSelectModal]); + + return ( + <Menu position="bottom-end" withArrow> + <Menu.Target> + <HeaderButton w="auto" px={4}> + <Group gap={4} wrap="nowrap"> + <IconPlus stroke={1.5} /> + <IconChevronDown color="gray" size={16} /> + </Group> + </HeaderButton> + </Menu.Target> + <Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}> + <Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}> + {t("item.action.create")} + </Menu.Item> + <Menu.Item leftSection={<IconPackageImport size={20} />}>{t("item.action.import")}</Menu.Item> + + <Menu.Divider /> + + <Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}> + {t("section.category.action.create")} + </Menu.Item> + </Menu.Dropdown> + </Menu> + ); +}; + +const EditModeMenu = () => { + const [isEditMode, setEditMode] = useEditMode(); + const board = useRequiredBoard(); + const utils = clientApi.useUtils(); + const t = useScopedI18n("board.action.edit"); + const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({ + onSuccess() { + showSuccessNotification({ + title: t("notification.success.title"), + message: t("notification.success.message"), + }); + void utils.board.getBoardByName.invalidate({ name: board.name }); + void revalidatePathActionAsync(`/boards/${board.name}`); + setEditMode(false); + }, + onError() { + showErrorNotification({ + title: t("notification.error.title"), + message: t("notification.error.message"), + }); + }, + }); + + const toggle = useCallback(() => { + if (isEditMode) return saveBoard(board); + setEditMode(true); + }, [board, isEditMode, saveBoard, setEditMode]); + + return ( + <HeaderButton onClick={toggle} loading={isPending}> + {isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />} + </HeaderButton> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx new file mode 100644 index 000000000..bf68bb902 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import type { MantineColorsTuple } from "@mantine/core"; +import { createTheme, darken, lighten, MantineProvider } from "@mantine/core"; + +import { useRequiredBoard } from "./_context"; + +export const BoardMantineProvider = ({ children }: PropsWithChildren) => { + const board = useRequiredBoard(); + + const theme = createTheme({ + colors: { + primaryColor: generateColors(board.primaryColor), + secondaryColor: generateColors(board.secondaryColor), + }, + primaryColor: "primaryColor", + autoContrast: true, + }); + + return <MantineProvider theme={theme}>{children}</MantineProvider>; +}; + +export const generateColors = (hex: string) => { + const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const; + const rgbaColors = lightnessForColors.map((lightness) => { + if (lightness < 0) { + return lighten(hex, -lightness); + } + return darken(hex, lightness); + }); + + return rgbaColors.map((color) => { + return ( + "#" + + color + .split("(")[1]! + .replaceAll(" ", "") + .replace(")", "") + .split(",") + .map((color) => parseInt(color, 10)) + .slice(0, 3) + .map((color) => color.toString(16).padStart(2, "0")) + .join("") + ); + }) as unknown as MantineColorsTuple; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx new file mode 100644 index 000000000..42107bf2d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx @@ -0,0 +1,12 @@ +import { api } from "@homarr/api/server"; + +import { BoardOtherHeaderActions } from "../_header-actions"; +import { createBoardLayout } from "../_layout-creator"; + +export default createBoardLayout<{ locale: string; name: string }>({ + headerActions: <BoardOtherHeaderActions />, + async getInitialBoardAsync({ name }) { + return await api.board.getBoardByName({ name }); + }, + isBoardContentPage: false, +}); diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx new file mode 100644 index 000000000..5739e98b6 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { Group, Stack, Tabs } from "@mantine/core"; +import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; +import { CountBadge } from "@homarr/ui"; + +import type { Board } from "../../_types"; +import { GroupsForm } from "./_access/group-access"; +import { InheritTable } from "./_access/inherit-access"; +import { UsersForm } from "./_access/user-access"; + +interface Props { + board: Board; + initialPermissions: RouterOutputs["board"]["getBoardPermissions"]; +} + +export const AccessSettingsContent = ({ board, initialPermissions }: Props) => { + const { data: permissions } = clientApi.board.getBoardPermissions.useQuery( + { + id: board.id, + }, + { + initialData: initialPermissions, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + + const [counts, setCounts] = useState({ + user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0), + group: initialPermissions.groupPermissions.length, + }); + + return ( + <Stack> + <Tabs color="red" defaultValue="user"> + <Tabs.List grow> + <TabItem value="user" count={counts.user} icon={IconUser} /> + <TabItem value="group" count={counts.group} icon={IconUsersGroup} /> + <TabItem value="inherited" count={initialPermissions.inherited.length} icon={IconUserDown} /> + </Tabs.List> + + <Tabs.Panel value="user"> + <UsersForm + board={board} + initialPermissions={permissions} + onCountChange={(callback) => + setCounts(({ user, ...others }) => ({ + user: callback(user), + ...others, + })) + } + /> + </Tabs.Panel> + + <Tabs.Panel value="group"> + <GroupsForm + board={board} + initialPermissions={permissions} + onCountChange={(callback) => + setCounts(({ group, ...others }) => ({ + group: callback(group), + ...others, + })) + } + /> + </Tabs.Panel> + + <Tabs.Panel value="inherited"> + <InheritTable initialPermissions={permissions} /> + </Tabs.Panel> + </Tabs> + </Stack> + ); +}; + +interface TabItemProps { + value: "user" | "group" | "inherited"; + count: number; + icon: TablerIcon; +} + +const TabItem = ({ value, icon: Icon, count }: TabItemProps) => { + const t = useScopedI18n("board.setting.section.access.permission"); + + return ( + <Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}> + <Group gap="sm"> + {t(`tab.${value}`)} + <CountBadge count={count} /> + </Group> + </Tabs.Tab> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/board-access-table-rows.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/board-access-table-rows.tsx new file mode 100644 index 000000000..4a370051a --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/board-access-table-rows.tsx @@ -0,0 +1,109 @@ +import { useCallback } from "react"; +import type { ReactNode } from "react"; +import type { SelectProps } from "@mantine/core"; +import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core"; +import { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react"; + +import type { BoardPermission } from "@homarr/definitions"; +import { boardPermissions } from "@homarr/definitions"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; + +import type { OnCountChange } from "./form"; +import { useFormContext } from "./form"; + +const icons = { + "board-change": IconPencil, + "board-view": IconEye, + "board-full": IconSettings, +} satisfies Record<BoardPermission | "board-full", TablerIcon>; + +interface BoardAccessSelectRowProps { + itemContent: ReactNode; + permission: BoardPermission; + index: number; + onCountChange: OnCountChange; +} + +export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountChange }: BoardAccessSelectRowProps) => { + const tRoot = useI18n(); + const tPermissions = useScopedI18n("board.setting.section.access.permission"); + const form = useFormContext(); + const Icon = icons[permission]; + + const handleRemove = useCallback(() => { + form.setFieldValue( + "items", + form.values.items.filter((_, i) => i !== index), + ); + onCountChange((prev) => prev - 1); + }, [form, index, onCountChange]); + + return ( + <TableTr> + <TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd> + <TableTd> + <Flex direction={{ base: "column", xs: "row" }} align={{ base: "end", xs: "center" }} wrap="nowrap"> + <Select + allowDeselect={false} + flex="1" + leftSection={<Icon size="1rem" />} + renderOption={RenderOption} + variant="unstyled" + data={boardPermissions.map((permission) => ({ + value: permission, + label: tPermissions(`item.${permission}.label`), + }))} + {...form.getInputProps(`items.${index}.permission`)} + /> + + <Button size="xs" variant="subtle" onClick={handleRemove}> + {tRoot("common.action.remove")} + </Button> + </Flex> + </TableTd> + </TableTr> + ); +}; + +interface BoardAccessDisplayRowProps { + itemContent: ReactNode; + permission: BoardPermission | "board-full"; +} + +export const BoardAccessDisplayRow = ({ itemContent, permission }: BoardAccessDisplayRowProps) => { + const tPermissions = useScopedI18n("board.setting.section.access.permission"); + const Icon = icons[permission]; + + return ( + <TableTr> + <TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd> + <TableTd> + <Group gap={0}> + <Flex w={34} h={34} align="center" justify="center"> + <Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" /> + </Flex> + <Text size="sm">{tPermissions(`item.${permission}.label`)}</Text> + </Group> + </TableTd> + </TableTr> + ); +}; + +const iconProps = { + stroke: 1.5, + color: "currentColor", + opacity: 0.6, + size: "1rem", +}; + +const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => { + const Icon = icons[option.value as BoardPermission]; + return ( + <Group flex="1" gap="xs" wrap="nowrap"> + <Icon {...iconProps} /> + {option.label} + {checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />} + </Group> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts new file mode 100644 index 000000000..08520b9d5 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/form.ts @@ -0,0 +1,13 @@ +import type { BoardPermission } from "@homarr/definitions"; +import { createFormContext } from "@homarr/form"; + +export interface BoardAccessFormType { + items: { + itemId: string; + permission: BoardPermission; + }[]; +} + +export const [FormProvider, useFormContext, useForm] = createFormContext<BoardAccessFormType>(); + +export type OnCountChange = (callback: (prev: number) => number) => void; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx new file mode 100644 index 000000000..f465b6c08 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-access.tsx @@ -0,0 +1,115 @@ +import { useCallback, useState } from "react"; +import Link from "next/link"; +import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; +import { IconPlus } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useModalAction } from "@homarr/modals"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; + +import { BoardAccessSelectRow } from "./board-access-table-rows"; +import type { BoardAccessFormType } from "./form"; +import { FormProvider, useForm } from "./form"; +import { GroupSelectModal } from "./group-select-modal"; +import type { FormProps } from "./user-access"; + +export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormProps) => { + const { mutate, isPending } = clientApi.board.saveGroupBoardPermissions.useMutation(); + const utils = clientApi.useUtils(); + const [groups, setGroups] = useState<Map<string, Group>>( + new Map(initialPermissions.groupPermissions.map(({ group }) => [group.id, group])), + ); + const { openModal } = useModalAction(GroupSelectModal); + const t = useI18n(); + const tPermissions = useScopedI18n("board.setting.section.access.permission"); + const form = useForm({ + initialValues: { + items: initialPermissions.groupPermissions.map(({ group, permission }) => ({ + itemId: group.id, + permission, + })), + }, + }); + + const handleSubmit = useCallback( + (values: BoardAccessFormType) => { + mutate( + { + id: board.id, + permissions: values.items, + }, + { + onSuccess: () => { + void utils.board.getBoardPermissions.invalidate(); + }, + }, + ); + }, + [board.id, mutate, utils.board.getBoardPermissions], + ); + + const handleAddUser = useCallback(() => { + openModal({ + presentGroupIds: form.values.items.map(({ itemId: id }) => id), + onSelect: (group) => { + setGroups((prev) => new Map(prev).set(group.id, group)); + form.setFieldValue("items", [ + { + itemId: group.id, + permission: "board-view", + }, + ...form.values.items, + ]); + onCountChange((prev) => prev + 1); + }, + }); + }, [form, openModal, onCountChange]); + + return ( + <form onSubmit={form.onSubmit(handleSubmit)}> + <FormProvider form={form}> + <Stack pt="sm"> + <Table> + <TableThead> + <TableTr> + <TableTh style={{ whiteSpace: "nowrap" }}>{tPermissions("field.group.label")}</TableTh> + <TableTh>{tPermissions("field.permission.label")}</TableTh> + </TableTr> + </TableThead> + <TableTbody> + {form.values.items.map((row, index) => ( + <BoardAccessSelectRow + key={row.itemId} + itemContent={<GroupItemContent group={groups.get(row.itemId)!} />} + permission={row.permission} + index={index} + onCountChange={onCountChange} + /> + ))} + </TableTbody> + </Table> + + <Group justify="space-between"> + <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> + {t("common.action.add")} + </Button> + <Button type="submit" loading={isPending} color="teal"> + {t("common.action.saveChanges")} + </Button> + </Group> + </Stack> + </FormProvider> + </form> + ); +}; + +export const GroupItemContent = ({ group }: { group: Group }) => { + return ( + <Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}> + {group.name} + </Anchor> + ); +}; + +type Group = RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"]; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx new file mode 100644 index 000000000..ef7ff2d5e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/group-select-modal.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { Button, Group, Loader, Select, Stack } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useForm } from "@homarr/form"; +import { createModal } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; + +interface InnerProps { + presentGroupIds: string[]; + onSelect: (props: { id: string; name: string }) => void | Promise<void>; + confirmLabel?: string; +} + +interface GroupSelectFormType { + groupId: string; +} + +export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { + const t = useI18n(); + const { data: groups, isPending } = clientApi.group.selectable.useQuery(); + const [loading, setLoading] = useState(false); + const form = useForm<GroupSelectFormType>(); + const handleSubmitAsync = async (values: GroupSelectFormType) => { + const currentGroup = groups?.find((group) => group.id === values.groupId); + if (!currentGroup) return; + setLoading(true); + await innerProps.onSelect({ + id: currentGroup.id, + name: currentGroup.name, + }); + + setLoading(false); + actions.closeModal(); + }; + + const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); + + return ( + <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> + <Stack> + <Select + {...form.getInputProps("groupId")} + label={t("group.action.select.label")} + clearable + searchable + leftSection={isPending ? <Loader size="xs" /> : undefined} + nothingFoundMessage={t("group.action.select.notFound")} + limit={5} + data={groups + ?.filter((group) => !innerProps.presentGroupIds.includes(group.id)) + .map((group) => ({ value: group.id, label: group.name }))} + /> + <Group justify="end"> + <Button variant="default" onClick={actions.closeModal}> + {t("common.action.cancel")} + </Button> + <Button type="submit" loading={loading}> + {confirmLabel} + </Button> + </Group> + </Stack> + </form> + ); +}).withOptions({ + defaultTitle: (t) => t("board.setting.section.access.permission.groupSelect.title"), +}); diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx new file mode 100644 index 000000000..d887ee696 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/inherit-access.tsx @@ -0,0 +1,57 @@ +import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { getPermissionsWithChildren } from "@homarr/definitions"; +import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { BoardAccessDisplayRow } from "./board-access-table-rows"; +import { GroupItemContent } from "./group-access"; + +export interface InheritTableProps { + initialPermissions: RouterOutputs["board"]["getBoardPermissions"]; +} + +const mapPermissions = { + "board-full-access": "board-full", + "board-modify-all": "board-change", + "board-view-all": "board-view", +} satisfies Partial<Record<GroupPermissionKey, BoardPermission | "board-full">>; + +export const InheritTable = ({ initialPermissions }: InheritTableProps) => { + const tPermissions = useScopedI18n("board.setting.section.access.permission"); + return ( + <Stack pt="sm"> + <Table> + <TableThead> + <TableTr> + <TableTh>{tPermissions("field.user.label")}</TableTh> + <TableTh>{tPermissions("field.permission.label")}</TableTh> + </TableTr> + </TableThead> + <TableTbody> + {initialPermissions.inherited.map(({ group, permission }) => { + const boardPermission = + permission in mapPermissions + ? mapPermissions[permission as keyof typeof mapPermissions] + : getPermissionsWithChildren([permission]).includes("board-full-access") + ? "board-full" + : null; + + if (!boardPermission) { + return null; + } + + return ( + <BoardAccessDisplayRow + key={group.id} + itemContent={<GroupItemContent group={group} />} + permission={boardPermission} + /> + ); + })} + </TableTbody> + </Table> + </Stack> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx new file mode 100644 index 000000000..8ed7e98ee --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-access.tsx @@ -0,0 +1,136 @@ +import { useCallback, useState } from "react"; +import Link from "next/link"; +import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; +import { IconPlus } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useModalAction } from "@homarr/modals"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { UserAvatar } from "@homarr/ui"; + +import type { Board } from "../../../_types"; +import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows"; +import type { BoardAccessFormType, OnCountChange } from "./form"; +import { FormProvider, useForm } from "./form"; +import { UserSelectModal } from "./user-select-modal"; + +export interface FormProps { + board: Pick<Board, "id" | "creatorId" | "creator">; + initialPermissions: RouterOutputs["board"]["getBoardPermissions"]; + onCountChange: OnCountChange; +} + +export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProps) => { + const { mutate, isPending } = clientApi.board.saveUserBoardPermissions.useMutation(); + const utils = clientApi.useUtils(); + const [users, setUsers] = useState<Map<string, User>>( + new Map(initialPermissions.userPermissions.map(({ user }) => [user.id, user])), + ); + const { openModal } = useModalAction(UserSelectModal); + const t = useI18n(); + const tPermissions = useScopedI18n("board.setting.section.access.permission"); + const form = useForm({ + initialValues: { + items: initialPermissions.userPermissions.map(({ user, permission }) => ({ + itemId: user.id, + permission, + })), + }, + }); + + const handleSubmit = useCallback( + (values: BoardAccessFormType) => { + mutate( + { + id: board.id, + permissions: values.items, + }, + { + onSuccess: () => { + void utils.board.getBoardPermissions.invalidate(); + }, + }, + ); + }, + [board.id, mutate, utils.board.getBoardPermissions], + ); + + const handleAddUser = useCallback(() => { + const presentUserIds = form.values.items.map(({ itemId: id }) => id); + + openModal({ + presentUserIds: board.creatorId ? presentUserIds.concat(board.creatorId) : presentUserIds, + onSelect: (user) => { + setUsers((prev) => new Map(prev).set(user.id, user)); + form.setFieldValue("items", [ + { + itemId: user.id, + permission: "board-view", + }, + ...form.values.items, + ]); + onCountChange((prev) => prev + 1); + }, + }); + }, [form, openModal, board.creatorId, onCountChange]); + + return ( + <form onSubmit={form.onSubmit(handleSubmit)}> + <FormProvider form={form}> + <Stack pt="sm"> + <Table> + <TableThead> + <TableTr> + <TableTh>{tPermissions("field.user.label")}</TableTh> + <TableTh>{tPermissions("field.permission.label")}</TableTh> + </TableTr> + </TableThead> + <TableTbody> + {board.creator && ( + <BoardAccessDisplayRow itemContent={<UserItemContent user={board.creator} />} permission="board-full" /> + )} + {form.values.items.map((row, index) => ( + <BoardAccessSelectRow + key={row.itemId} + itemContent={<UserItemContent user={users.get(row.itemId)!} />} + permission={row.permission} + index={index} + onCountChange={onCountChange} + /> + ))} + </TableTbody> + </Table> + + <Group justify="space-between"> + <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> + {t("common.action.add")} + </Button> + <Button type="submit" loading={isPending} color="teal"> + {t("common.action.saveChanges")} + </Button> + </Group> + </Stack> + </FormProvider> + </form> + ); +}; + +const UserItemContent = ({ user }: { user: User }) => { + return ( + <Group wrap="nowrap"> + <Box visibleFrom="xs"> + <UserAvatar user={user} size="sm" /> + </Box> + <Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}> + {user.name} + </Anchor> + </Group> + ); +}; + +interface User { + id: string; + name: string | null; + image: string | null; +} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-select-modal.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-select-modal.tsx new file mode 100644 index 000000000..d4c4946ff --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_access/user-select-modal.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import type { SelectProps } from "@mantine/core"; +import { Button, Group, Loader, Select, Stack } from "@mantine/core"; +import { IconCheck } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useForm } from "@homarr/form"; +import { createModal } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; +import { UserAvatar } from "@homarr/ui"; + +interface InnerProps { + presentUserIds: string[]; + onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>; + confirmLabel?: string; +} + +interface UserSelectFormType { + userId: string; +} + +export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { + const t = useI18n(); + const { data: users, isPending } = clientApi.user.selectable.useQuery(); + const [loading, setLoading] = useState(false); + const form = useForm<UserSelectFormType>(); + const handleSubmitAsync = async (values: UserSelectFormType) => { + const currentUser = users?.find((user) => user.id === values.userId); + if (!currentUser) return; + setLoading(true); + await innerProps.onSelect({ + id: currentUser.id, + name: currentUser.name ?? "", + image: currentUser.image ?? "", + }); + + setLoading(false); + actions.closeModal(); + }; + + const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); + const currentUser = users?.find((user) => user.id === form.values.userId); + + return ( + <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> + <Stack> + <Select + {...form.getInputProps("userId")} + label={t("user.action.select.label")} + searchable + clearable + leftSection={ + isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined + } + nothingFoundMessage={t("user.action.select.notFound")} + renderOption={createRenderOption(users ?? [])} + limit={5} + data={users + ?.filter((user) => !innerProps.presentUserIds.includes(user.id)) + .map((user) => ({ value: user.id, label: user.name ?? "" }))} + /> + <Group justify="end"> + <Button variant="default" onClick={actions.closeModal}> + {t("common.action.cancel")} + </Button> + <Button type="submit" loading={loading}> + {confirmLabel} + </Button> + </Group> + </Stack> + </form> + ); +}).withOptions({ + defaultTitle: (t) => t("board.setting.section.access.permission.userSelect.title"), +}); + +const iconProps = { + stroke: 1.5, + color: "currentColor", + opacity: 0.6, + size: "1rem", +}; + +const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] => + function InnerRenderRoot({ option, checked }) { + const user = users.find((user) => user.id === option.value); + if (!user) return null; + + return ( + <Group flex="1" gap="xs"> + <UserAvatar user={user} size="xs" /> + {option.label} + {checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />} + </Group> + ); + }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx new file mode 100644 index 000000000..9f790cf7c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { Button, Grid, Group, Stack, TextInput } from "@mantine/core"; + +import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; +import { useZodForm } from "@homarr/form"; +import type { TranslationObject } from "@homarr/translation"; +import { useI18n } from "@homarr/translation/client"; +import type { SelectItemWithDescriptionBadge } from "@homarr/ui"; +import { SelectWithDescriptionBadge } from "@homarr/ui"; +import { validation } from "@homarr/validation"; + +import type { Board } from "../../_types"; +import { useSavePartialSettingsMutation } from "./_shared"; + +interface Props { + board: Board; +} +export const BackgroundSettingsContent = ({ board }: Props) => { + const t = useI18n(); + const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); + const form = useZodForm(validation.board.savePartialSettings, { + initialValues: { + backgroundImageUrl: board.backgroundImageUrl ?? "", + backgroundImageAttachment: board.backgroundImageAttachment, + backgroundImageRepeat: board.backgroundImageRepeat, + backgroundImageSize: board.backgroundImageSize, + }, + }); + + const backgroundImageAttachmentData = useBackgroundOptionData( + "backgroundImageAttachment", + backgroundImageAttachments, + ); + const backgroundImageSizeData = useBackgroundOptionData("backgroundImageSize", backgroundImageSizes); + const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats); + + return ( + <form + onSubmit={form.onSubmit((values) => { + savePartialSettings({ + id: board.id, + ...values, + }); + })} + > + <Stack> + <Grid> + <Grid.Col span={12}> + <TextInput + label={t("board.field.backgroundImageUrl.label")} + {...form.getInputProps("backgroundImageUrl")} + /> + </Grid.Col> + <Grid.Col span={12}> + <SelectWithDescriptionBadge + label={t("board.field.backgroundImageAttachment.label")} + data={backgroundImageAttachmentData} + {...form.getInputProps("backgroundImageAttachment")} + /> + </Grid.Col> + <Grid.Col span={12}> + <SelectWithDescriptionBadge + label={t("board.field.backgroundImageSize.label")} + data={backgroundImageSizeData} + {...form.getInputProps("backgroundImageSize")} + /> + </Grid.Col> + <Grid.Col span={12}> + <SelectWithDescriptionBadge + label={t("board.field.backgroundImageRepeat.label")} + data={backgroundImageRepeatData} + {...form.getInputProps("backgroundImageRepeat")} + /> + </Grid.Col> + </Grid> + + <Group justify="end"> + <Button type="submit" loading={isPending} color="teal"> + {t("common.action.saveChanges")} + </Button> + </Group> + </Stack> + </form> + ); +}; + +type BackgroundImageKey = "backgroundImageAttachment" | "backgroundImageSize" | "backgroundImageRepeat"; + +type inferOptions<TKey extends BackgroundImageKey> = TranslationObject["board"]["field"][TKey]["option"]; + +const useBackgroundOptionData = < + TKey extends BackgroundImageKey, + TOptions extends inferOptions<TKey> = inferOptions<TKey>, +>( + key: TKey, + data: { + values: (keyof TOptions)[]; + defaultValue: keyof TOptions; + }, +) => { + const t = useI18n(); + + return data.values.map( + (value) => + ({ + label: t(`board.field.${key}.option.${value as string}.label` as never), + description: t(`board.field.${key}.option.${value as string}.description` as never), + value: value as string, + badge: + data.defaultValue === value + ? { + color: "blue", + label: t("common.select.badge.recommended"), + } + : undefined, + }) satisfies SelectItemWithDescriptionBadge, + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx new file mode 100644 index 000000000..4f12e28ad --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { + Anchor, + Button, + Collapse, + ColorInput, + ColorSwatch, + Grid, + Group, + InputWrapper, + isLightColor, + Slider, + Stack, + Text, + useMantineTheme, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; + +import { useZodForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; +import { validation } from "@homarr/validation"; + +import type { Board } from "../../_types"; +import { generateColors } from "../../(content)/_theme"; +import { useSavePartialSettingsMutation } from "./_shared"; + +interface Props { + board: Board; +} + +const hexRegex = /^#[0-9a-fA-F]{6}$/; + +const progressPercentageLabel = (value: number) => `${value}%`; + +export const ColorSettingsContent = ({ board }: Props) => { + const form = useZodForm(validation.board.savePartialSettings, { + initialValues: { + primaryColor: board.primaryColor, + secondaryColor: board.secondaryColor, + opacity: board.opacity, + }, + }); + const [showPreview, { toggle }] = useDisclosure(false); + const t = useI18n(); + const theme = useMantineTheme(); + const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); + + return ( + <form + onSubmit={form.onSubmit((values) => { + savePartialSettings({ + id: board.id, + ...values, + }); + })} + > + <Stack> + <Grid> + <Grid.Col span={{ sm: 12, md: 6 }}> + <Stack gap="xs"> + <ColorInput + label={t("board.field.primaryColor.label")} + format="hex" + swatches={Object.values(theme.colors).map((color) => color[6])} + {...form.getInputProps("primaryColor")} + /> + </Stack> + </Grid.Col> + <Grid.Col span={{ sm: 12, md: 6 }}> + <ColorInput + label={t("board.field.secondaryColor.label")} + format="hex" + swatches={Object.values(theme.colors).map((color) => color[6])} + {...form.getInputProps("secondaryColor")} + /> + </Grid.Col> + <Grid.Col span={12}> + <Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor> + </Grid.Col> + <Grid.Col span={12}> + <Collapse in={showPreview}> + <Stack> + <ColorsPreview previewColor={form.values.primaryColor} /> + <ColorsPreview previewColor={form.values.secondaryColor} /> + </Stack> + </Collapse> + </Grid.Col> + <Grid.Col span={{ sm: 12, md: 6 }}> + <InputWrapper label={t("board.field.opacity.label")}> + <Slider + my={6} + min={0} + max={100} + step={5} + label={progressPercentageLabel} + {...form.getInputProps("opacity")} + /> + </InputWrapper> + </Grid.Col> + </Grid> + <Group justify="end"> + <Button type="submit" loading={isPending} color="teal"> + {t("common.action.saveChanges")} + </Button> + </Group> + </Stack> + </form> + ); +}; + +interface ColorsPreviewProps { + previewColor: string | undefined; +} + +const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => { + const theme = useMantineTheme(); + + const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000"); + + return ( + <Group gap={0} wrap="nowrap"> + {colors.map((color, index) => ( + <ColorSwatch + key={index} + color={color} + w="10%" + pb="10%" + c={isLightColor(color) ? "black" : "white"} + radius={0} + styles={{ + colorOverlay: { + borderTopLeftRadius: index === 0 ? theme.radius.md : 0, + borderBottomLeftRadius: index === 0 ? theme.radius.md : 0, + borderTopRightRadius: index === 9 ? theme.radius.md : 0, + borderBottomRightRadius: index === 9 ? theme.radius.md : 0, + }, + }} + > + <Stack align="center" gap={4}> + <Text visibleFrom="md" fw={500} size="lg"> + {index} + </Text> + <Text visibleFrom="md" fw={500} size="xs" tt="uppercase"> + {color} + </Text> + </Stack> + </ColorSwatch> + ))} + </Group> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx new file mode 100644 index 000000000..4e007388f --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Alert, Button, Group, Input, Stack } from "@mantine/core"; +import { highlight, languages } from "prismjs"; +import Editor from "react-simple-code-editor"; + +import "~/styles/prismjs.scss"; + +import { IconInfoCircle } from "@tabler/icons-react"; + +import { useForm } from "@homarr/form"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; + +import type { Board } from "../../_types"; +import { useSavePartialSettingsMutation } from "./_shared"; +import classes from "./customcss.module.css"; + +interface Props { + board: Board; +} + +export const CustomCssSettingsContent = ({ board }: Props) => { + const t = useI18n(); + const customCssT = useScopedI18n("board.field.customCss"); + const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); + const form = useForm({ + initialValues: { + customCss: board.customCss ?? "", + }, + }); + + return ( + <form + onSubmit={form.onSubmit((values) => { + savePartialSettings({ + id: board.id, + ...values, + }); + })} + > + <Stack> + <CustomCssInput {...form.getInputProps("customCss")} /> + + <Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}> + {customCssT("customClassesAlert.description")} + </Alert> + + <Group justify="end"> + <Button type="submit" loading={isPending} color="teal"> + {t("common.action.saveChanges")} + </Button> + </Group> + </Stack> + </form> + ); +}; + +interface CustomCssInputProps { + value?: string; + onChange: (value: string) => void; +} + +const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => { + const customCssT = useScopedI18n("board.field.customCss"); + + return ( + <Input.Wrapper + label={customCssT("label")} + labelProps={{ + htmlFor: "custom-css", + }} + description={customCssT("description")} + inputWrapperOrder={["label", "description", "input", "error"]} + > + <div className={classes.codeEditorRoot}> + <Editor + textareaId="custom-css" + onValueChange={onChange} + value={value ?? ""} + highlight={(code) => highlight(code, languages.extend("css", {}), "css")} + padding={10} + style={{ + fontFamily: '"Fira code", "Fira Mono", monospace', + fontSize: 12, + minHeight: 250, + }} + /> + </div> + </Input.Wrapper> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx new file mode 100644 index 000000000..fa1c05bd3 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Button, Divider, Group, Stack, Text } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useConfirmModal, useModalAction } from "@homarr/modals"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; +import { useRequiredBoard } from "../../(content)/_context"; +import classes from "./danger.module.css"; + +export const DangerZoneSettingsContent = () => { + const board = useRequiredBoard(); + const t = useScopedI18n("board.setting"); + const router = useRouter(); + const { openConfirmModal } = useConfirmModal(); + const { openModal } = useModalAction(BoardRenameModal); + const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = + clientApi.board.changeBoardVisibility.useMutation(); + const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation(); + const utils = clientApi.useUtils(); + const visibility = board.isPublic ? "public" : "private"; + + const onRenameClick = useCallback( + () => + openModal({ + id: board.id, + previousName: board.name, + onSuccess: (name) => router.push(`/boards/${name}/settings`), + }), + [board.id, board.name, router, openModal], + ); + + const onVisibilityClick = useCallback(() => { + openConfirmModal({ + title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`), + children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`), + onConfirm: () => { + changeVisibility( + { + id: board.id, + visibility: visibility === "public" ? "private" : "public", + }, + { + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, + }, + ); + }, + }); + }, [ + board.id, + board.name, + changeVisibility, + t, + utils.board.getBoardByName, + utils.board.getHomeBoard, + visibility, + openConfirmModal, + ]); + + const onDeleteClick = useCallback(() => { + openConfirmModal({ + title: t("section.dangerZone.action.delete.confirm.title"), + children: t("section.dangerZone.action.delete.confirm.description"), + onConfirm: () => { + deleteBoard( + { id: board.id }, + { + onSettled: () => { + router.push("/"); + }, + }, + ); + }, + }); + }, [board.id, deleteBoard, router, t, openConfirmModal]); + + return ( + <Stack gap="sm"> + <Divider /> + <DangerZoneRow + label={t("section.dangerZone.action.rename.label")} + description={t("section.dangerZone.action.rename.description")} + buttonText={t("section.dangerZone.action.rename.button")} + onClick={onRenameClick} + /> + <Divider /> + <DangerZoneRow + label={t("section.dangerZone.action.visibility.label")} + description={t(`section.dangerZone.action.visibility.description.${visibility}`)} + buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)} + onClick={onVisibilityClick} + isPending={isChangeVisibilityPending} + /> + <Divider /> + <DangerZoneRow + label={t("section.dangerZone.action.delete.label")} + description={t("section.dangerZone.action.delete.description")} + buttonText={t("section.dangerZone.action.delete.button")} + onClick={onDeleteClick} + isPending={isDeletePending} + /> + </Stack> + ); +}; + +interface DangerZoneRowProps { + label: string; + description: string; + buttonText: string; + isPending?: boolean; + onClick: () => void; +} + +const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => { + return ( + <Group justify="space-between" px="md" className={classes.dangerZoneGroup}> + <Stack gap={0}> + <Text fw="bold" size="sm"> + {label} + </Text> + <Text size="sm">{description}</Text> + </Stack> + <Group justify="end" w={{ base: "100%", xs: "auto" }}> + <Button variant="subtle" color="red" loading={isPending} onClick={onClick}> + {buttonText} + </Button> + </Group> + </Group> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx new file mode 100644 index 000000000..0303be98d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine/core"; +import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useZodForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; +import { validation } from "@homarr/validation"; + +import { createMetaTitle } from "~/metadata"; +import type { Board } from "../../_types"; +import { useUpdateBoard } from "../../(content)/_client"; +import { useSavePartialSettingsMutation } from "./_shared"; + +interface Props { + board: Board; +} + +export const GeneralSettingsContent = ({ board }: Props) => { + const t = useI18n(); + const ref = useRef({ + pageTitle: board.pageTitle, + logoImageUrl: board.logoImageUrl, + }); + const { updateBoard } = useUpdateBoard(); + + const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); + const form = useZodForm( + validation.board.savePartialSettings + .pick({ + pageTitle: true, + logoImageUrl: true, + metaTitle: true, + faviconImageUrl: true, + }) + .required(), + { + initialValues: { + pageTitle: board.pageTitle ?? "", + logoImageUrl: board.logoImageUrl ?? "", + metaTitle: board.metaTitle ?? "", + faviconImageUrl: board.faviconImageUrl ?? "", + }, + onValuesChange({ pageTitle }) { + updateBoard((previous) => ({ + ...previous, + pageTitle, + })); + }, + }, + ); + + const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle); + const faviconStatus = useFaviconPreview(form.values.faviconImageUrl); + const logoStatus = useLogoPreview(form.values.logoImageUrl); + + // Cleanup for not applied changes of the page title and logo image URL + useEffect(() => { + return () => { + updateBoard((previous) => ({ + ...previous, + pageTitle: ref.current.pageTitle, + logoImageUrl: ref.current.logoImageUrl, + })); + }; + }, [updateBoard]); + + return ( + <form + onSubmit={form.onSubmit((values) => { + // Save the current values to the ref so that it does not reset if the form is submitted + ref.current = { + pageTitle: values.pageTitle, + logoImageUrl: values.logoImageUrl, + }; + savePartialSettings({ + id: board.id, + ...values, + }); + })} + > + <Stack> + <Grid> + <Grid.Col span={{ xs: 12, md: 6 }}> + <TextInput + label={t("board.field.pageTitle.label")} + placeholder="Homarr" + {...form.getInputProps("pageTitle")} + /> + </Grid.Col> + <Grid.Col span={{ xs: 12, md: 6 }}> + <TextInput + label={t("board.field.metaTitle.label")} + placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))} + rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />} + {...form.getInputProps("metaTitle")} + /> + </Grid.Col> + <Grid.Col span={{ xs: 12, md: 6 }}> + <TextInput + label={t("board.field.logoImageUrl.label")} + placeholder="/logo/logo.png" + rightSection={<PendingOrInvalidIndicator {...logoStatus} />} + {...form.getInputProps("logoImageUrl")} + /> + </Grid.Col> + <Grid.Col span={{ xs: 12, md: 6 }}> + <TextInput + label={t("board.field.faviconImageUrl.label")} + placeholder="/logo/logo.png" + rightSection={<PendingOrInvalidIndicator {...faviconStatus} />} + {...form.getInputProps("faviconImageUrl")} + /> + </Grid.Col> + </Grid> + <Group justify="end"> + <Button type="submit" loading={isPending} color="teal"> + {t("common.action.saveChanges")} + </Button> + </Group> + </Stack> + </form> + ); +}; + +const PendingOrInvalidIndicator = ({ isPending, isInvalid }: { isPending: boolean; isInvalid?: boolean }) => { + const t = useI18n(); + + if (isInvalid) { + return ( + <Tooltip multiline w={220} label={t("board.setting.section.general.unrecognizedLink")}> + <IconAlertTriangle size="1rem" color="red" /> + </Tooltip> + ); + } + + if (isPending) { + return <Loader size="xs" />; + } + + return null; +}; + +const useLogoPreview = (url: string | null) => { + const { updateBoard } = useUpdateBoard(); + const [logoDebounced] = useDebouncedValue(url ?? "", 500); + + useEffect(() => { + if (!logoDebounced.includes(".") && logoDebounced.length >= 1) return; + updateBoard((previous) => ({ + ...previous, + logoImageUrl: logoDebounced.length >= 1 ? logoDebounced : null, + })); + }, [logoDebounced, updateBoard]); + + return { + isPending: (url ?? "") !== logoDebounced, + isInvalid: logoDebounced.length >= 1 && !logoDebounced.includes("."), + }; +}; + +const useMetaTitlePreview = (title: string | null) => { + const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200); + useDocumentTitle(metaTitleDebounced); + + return { + isPending: (title ?? "") !== metaTitleDebounced, + }; +}; + +const validFaviconExtensions = ["ico", "png", "svg", "gif"]; +const isValidUrl = (url: string) => + url.includes("/") && validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`)); + +const useFaviconPreview = (url: string | null) => { + const [faviconDebounced] = useDebouncedValue(url ?? "", 500); + useFavicon(isValidUrl(faviconDebounced) ? faviconDebounced : ""); + + return { + isPending: (url ?? "") !== faviconDebounced, + isInvalid: faviconDebounced.length >= 1 && !isValidUrl(faviconDebounced), + }; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx new file mode 100644 index 000000000..f0c2eeb9f --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core"; + +import { useZodForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; +import { validation } from "@homarr/validation"; + +import type { Board } from "../../_types"; +import { useSavePartialSettingsMutation } from "./_shared"; + +interface Props { + board: Board; +} +export const LayoutSettingsContent = ({ board }: Props) => { + const t = useI18n(); + const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); + const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), { + initialValues: { + columnCount: board.columnCount, + }, + }); + + return ( + <form + onSubmit={form.onSubmit((values) => { + savePartialSettings({ + id: board.id, + ...values, + }); + })} + > + <Stack> + <Grid> + <Grid.Col span={{ sm: 12, md: 6 }}> + <Input.Wrapper label={t("board.field.columnCount.label")}> + <Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} /> + </Input.Wrapper> + </Grid.Col> + </Grid> + <Group justify="end"> + <Button type="submit" loading={isPending} color="teal"> + {t("common.action.saveChanges")} + </Button> + </Group> + </Stack> + </form> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx new file mode 100644 index 000000000..9fa888be2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx @@ -0,0 +1,13 @@ +import { clientApi } from "@homarr/api/client"; + +import type { Board } from "../../_types"; + +export const useSavePartialSettingsMutation = (board: Board) => { + const utils = clientApi.useUtils(); + return clientApi.board.savePartialBoardSettings.useMutation({ + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, + }); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/customcss.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/customcss.module.css new file mode 100644 index 000000000..b01c21383 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/customcss.module.css @@ -0,0 +1,22 @@ +.codeEditorFooter { + border-bottom-left-radius: var(--mantine-radius-sm); + border-bottom-right-radius: var(--mantine-radius-sm); + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7)); +} + +.codeEditorRoot { + margin-top: 4px; + border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4)); + border-width: 1px; + border-style: solid; + border-radius: var(--mantine-radius-sm); +} + +.codeEditor { + background-color: light-dark(white, var(--mantine-color-dark-6)); + font-size: var(--mantine-font-size-xs); +} + +.codeEditor ::placeholder { + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); +} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css new file mode 100644 index 000000000..39a60b735 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css @@ -0,0 +1,5 @@ +@media (min-width: 36em) { + .dangerZoneGroup { + --group-wrap: nowrap !important; + } +} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx new file mode 100644 index 000000000..1748ee36b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -0,0 +1,138 @@ +import type { PropsWithChildren } from "react"; +import { notFound } from "next/navigation"; +import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core"; +import { + IconAlertTriangle, + IconBrush, + IconFileTypeCss, + IconLayout, + IconPhoto, + IconSettings, + IconUser, +} from "@tabler/icons-react"; +import { TRPCError } from "@trpc/server"; + +import { api } from "@homarr/api/server"; +import { capitalize } from "@homarr/common"; +import type { TranslationObject } from "@homarr/translation"; +import { getScopedI18n } from "@homarr/translation/server"; +import type { TablerIcon } from "@homarr/ui"; + +import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; +import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; +import { AccessSettingsContent } from "./_access"; +import { BackgroundSettingsContent } from "./_background"; +import { ColorSettingsContent } from "./_colors"; +import { CustomCssSettingsContent } from "./_customCss"; +import { DangerZoneSettingsContent } from "./_danger"; +import { GeneralSettingsContent } from "./_general"; +import { LayoutSettingsContent } from "./_layout"; + +interface Props { + params: { + name: string; + }; + searchParams: { + tab?: keyof TranslationObject["board"]["setting"]["section"]; + }; +} + +const getBoardAndPermissionsAsync = async (params: Props["params"]) => { + try { + const board = await api.board.getBoardByName({ name: params.name }); + const { hasFullAccess } = await getBoardPermissionsAsync(board); + const permissions = hasFullAccess + ? await api.board.getBoardPermissions({ id: board.id }) + : { + userPermissions: [], + groupPermissions: [], + inherited: [], + }; + + return { board, permissions }; + } catch (error) { + // Ignore not found errors and redirect to 404 + // error is already logged in _layout-creator.tsx + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + notFound(); + } + + throw error; + } +}; + +export default async function BoardSettingsPage({ params, searchParams }: Props) { + const { board, permissions } = await getBoardAndPermissionsAsync(params); + const { hasFullAccess } = await getBoardPermissionsAsync(board); + const t = await getScopedI18n("board.setting"); + + return ( + <Container> + <Stack> + <Title>{t("title", { boardName: capitalize(board.name) })} + + + + + + + + + + + + + + + + + {hasFullAccess && ( + <> + + + + + + + + )} + + + + ); +} + +type AccordionItemForProps = PropsWithChildren<{ + value: keyof TranslationObject["board"]["setting"]["section"]; + icon: TablerIcon; + danger?: boolean; + noPadding?: boolean; +}>; + +const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => { + const t = await getScopedI18n("board.setting.section"); + return ( + + }> + + {t(`${value}.title`)} + + + + {children} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx new file mode 100644 index 000000000..299bbb05f --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { IconLayoutBoard } from "@tabler/icons-react"; + +import { HeaderButton } from "~/components/layout/header/button"; +import { useRequiredBoard } from "./(content)/_context"; + +export const BoardOtherHeaderActions = () => { + const board = useRequiredBoard(); + + return ( + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx new file mode 100644 index 000000000..56dabdd88 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -0,0 +1,64 @@ +import type { PropsWithChildren } from "react"; +import { notFound } from "next/navigation"; +import { AppShellMain } from "@mantine/core"; +import { TRPCError } from "@trpc/server"; + +import { logger } from "@homarr/log"; +import { GlobalItemServerDataRunner } from "@homarr/widgets"; + +import { MainHeader } from "~/components/layout/header"; +import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; +import { ClientShell } from "~/components/layout/shell"; +import type { Board } from "./_types"; +import { BoardProvider } from "./(content)/_context"; +import type { Params } from "./(content)/_creator"; +import { CustomCss } from "./(content)/_custom-css"; +import { BoardMantineProvider } from "./(content)/_theme"; + +interface CreateBoardLayoutProps { + headerActions: JSX.Element; + getInitialBoardAsync: (params: TParams) => Promise; + isBoardContentPage: boolean; +} + +export const createBoardLayout = ({ + headerActions, + getInitialBoardAsync: getInitialBoard, + isBoardContentPage, +}: CreateBoardLayoutProps) => { + const Layout = async ({ + params, + children, + }: PropsWithChildren<{ + params: TParams; + }>) => { + const initialBoard = await getInitialBoard(params).catch((error) => { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + logger.warn(error); + notFound(); + } + + throw error; + }); + + return ( + + + + + + } + actions={headerActions} + hasNavigation={false} + /> + {children} + + + + + ); + }; + + return Layout; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_types.ts b/apps/nextjs/src/app/[locale]/boards/_types.ts new file mode 100644 index 000000000..4d841d1ac --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_types.ts @@ -0,0 +1,11 @@ +import type { RouterOutputs } from "@homarr/api"; +import type { WidgetKind } from "@homarr/definitions"; + +export type Board = RouterOutputs["board"]["getHomeBoard"]; +export type Section = Board["sections"][number]; +export type Item = Section["items"][number]; + +export type CategorySection = Extract; +export type EmptySection = Extract; + +export type ItemOfKind = Extract; diff --git a/apps/nextjs/src/app/[locale]/compose.tsx b/apps/nextjs/src/app/[locale]/compose.tsx new file mode 100644 index 000000000..6997ca986 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/compose.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +type PropsWithChildren = Required; + +export const composeWrappers = ( + wrappers: React.FunctionComponent[], +): React.FunctionComponent => { + return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent => { + // eslint-disable-next-line react/display-name + return (props) => ( + + + + ); + }); +}; diff --git a/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx b/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx index 913d6ee57..516b3b898 100644 --- a/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx +++ b/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx @@ -1,27 +1,20 @@ "use client"; import { useRouter } from "next/navigation"; +import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; -import { useForm, zodResolver } from "@homarr/form"; -import { - showErrorNotification, - showSuccessNotification, -} from "@homarr/notifications"; +import { clientApi } from "@homarr/api/client"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import { Button, PasswordInput, Stack, TextInput } from "@homarr/ui"; import type { z } from "@homarr/validation"; import { validation } from "@homarr/validation"; -import { api } from "~/trpc/react"; - export const InitUserForm = () => { const router = useRouter(); const t = useScopedI18n("user"); - const { mutateAsync, error, isPending } = api.user.initUser.useMutation(); - const form = useForm({ - validate: zodResolver(validation.user.init), - validateInputOnBlur: true, - validateInputOnChange: true, + const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation(); + const form = useZodForm(validation.user.init, { initialValues: { username: "", password: "", @@ -29,8 +22,7 @@ export const InitUserForm = () => { }, }); - const handleSubmit = async (values: FormType) => { - console.log(values); + const handleSubmitAsync = async (values: FormType) => { await mutateAsync(values, { onSuccess: () => { showSuccessNotification({ @@ -52,23 +44,14 @@ export const InitUserForm = () => {
void handleSubmit(v), + (values) => void handleSubmitAsync(values), (err) => console.log(err), )} > - - - + + + diff --git a/apps/nextjs/src/app/[locale]/init/user/page.tsx b/apps/nextjs/src/app/[locale]/init/user/page.tsx index 63e87e249..95fdaa145 100644 --- a/apps/nextjs/src/app/[locale]/init/user/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/user/page.tsx @@ -1,10 +1,10 @@ import { notFound } from "next/navigation"; +import { Card, Center, Stack, Text, Title } from "@mantine/core"; import { db } from "@homarr/db"; import { getScopedI18n } from "@homarr/translation/server"; -import { Card, Center, Stack, Text, Title } from "@homarr/ui"; -import { LogoWithTitle } from "~/components/layout/logo"; +import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { InitUserForm } from "./_init-user-form"; export default async function InitUser() { @@ -23,7 +23,7 @@ export default async function InitUser() { return (
- + {t("title")} diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index d74b5e917..35414e252 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -1,65 +1,87 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; import "@homarr/ui/styles.css"; import "@homarr/notifications/styles.css"; import "@homarr/spotlight/styles.css"; -import { headers } from "next/headers"; +import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core"; +import { auth } from "@homarr/auth/next"; +import { ModalProvider } from "@homarr/modals"; import { Notifications } from "@homarr/notifications"; -import { - ColorSchemeScript, - MantineProvider, - uiConfiguration, -} from "@homarr/ui"; -import { ModalsProvider } from "./_client-providers/modals"; +import { JotaiProvider } from "./_client-providers/jotai"; import { NextInternationalProvider } from "./_client-providers/next-international"; +import { AuthProvider } from "./_client-providers/session"; import { TRPCReactProvider } from "./_client-providers/trpc"; +import { composeWrappers } from "./compose"; const fontSans = Inter({ subsets: ["latin"], variable: "--font-sans", }); -/** - * Since we're passing `headers()` to the `TRPCReactProvider` we need to - * make the entire app dynamic. You can move the `TRPCReactProvider` further - * down the tree (e.g. /dashboard and onwards) to make part of the app statically rendered. - */ -export const dynamic = "force-dynamic"; - export const metadata: Metadata = { - title: "Create T3 Turbo", - description: "Simple monorepo with shared backend for web & mobile apps", + metadataBase: new URL("http://localhost:3000"), + title: "Homarr", + description: + "Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.", + openGraph: { + title: "Homarr Dashboard", + description: + "Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.", + url: "https://homarr.dev", + siteName: "Homarr Documentation", + }, + twitter: { + card: "summary_large_image", + site: "@jullerino", + creator: "@jullerino", + }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "white" }, + { media: "(prefers-color-scheme: dark)", color: "black" }, + ], }; -export default function Layout(props: { - children: React.ReactNode; - params: { locale: string }; -}) { +export default function Layout(props: { children: React.ReactNode; params: { locale: string } }) { const colorScheme = "dark"; + const StackedProvider = composeWrappers([ + async (innerProps) => { + const session = await auth(); + return <AuthProvider session={session} {...innerProps} />; + }, + (innerProps) => <JotaiProvider {...innerProps} />, + (innerProps) => <TRPCReactProvider {...innerProps} />, + (innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />, + (innerProps) => ( + <MantineProvider + {...innerProps} + defaultColorScheme="dark" + theme={createTheme({ + primaryColor: "red", + autoContrast: true, + })} + /> + ), + (innerProps) => <ModalProvider {...innerProps} />, + ]); + return ( - <html lang="en"> + <html lang="en" suppressHydrationWarning> <head> <ColorSchemeScript defaultColorScheme={colorScheme} /> </head> <body className={["font-sans", fontSans.variable].join(" ")}> - <TRPCReactProvider headers={headers()}> - <NextInternationalProvider locale={props.params.locale}> - <MantineProvider - defaultColorScheme={colorScheme} - {...uiConfiguration} - > - <ModalsProvider> - <Notifications /> - {props.children} - </ModalsProvider> - </MantineProvider> - </NextInternationalProvider> - </TRPCReactProvider> + <StackedProvider> + <Notifications /> + {props.children} + </StackedProvider> </body> </html> ); diff --git a/apps/nextjs/src/app/[locale]/loading.tsx b/apps/nextjs/src/app/[locale]/loading.tsx index 219a9658c..201f429d0 100644 --- a/apps/nextjs/src/app/[locale]/loading.tsx +++ b/apps/nextjs/src/app/[locale]/loading.tsx @@ -1,4 +1,4 @@ -import { Center, Loader } from "@homarr/ui"; +import { Center, Loader } from "@mantine/core"; export default function CommonLoading() { return ( diff --git a/apps/nextjs/src/app/[locale]/manage/[...not-found]/page.tsx b/apps/nextjs/src/app/[locale]/manage/[...not-found]/page.tsx new file mode 100644 index 000000000..d7b31a978 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/[...not-found]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from "next/navigation"; + +export default function NotFound() { + return notFound(); +} diff --git a/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.module.css b/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.module.css new file mode 100644 index 000000000..cfadc375d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.module.css @@ -0,0 +1,37 @@ +.bannerContainer { + padding: 3rem; + border-radius: 8px; + overflow: hidden; + background: linear-gradient( + 130deg, + #fa52521f 0%, + var(--mantine-color-dark-6) 35%, + var(--mantine-color-dark-6) 100% + ) !important; +} + +.scrollContainer { + height: 100%; + transform: rotateZ(10deg); +} + +@keyframes scrolling { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(-50%); + } +} + +.scrollAnimationContainer { + animation: scrolling; + animation-timing-function: linear; + animation-iteration-count: infinite; +} + +@media (prefers-reduced-motion: reduce) { + .scrollAnimationContainer { + animation: none !important; + } +} diff --git a/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx b/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx new file mode 100644 index 000000000..35943bcf9 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx @@ -0,0 +1,78 @@ +import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core"; + +import { splitToNChunks } from "@homarr/common"; + +import classes from "./hero-banner.module.css"; + +const icons = [ + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg", + "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg", + "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg", + "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg", + "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg", + "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png", + "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg", +]; + +const countIconGroups = 3; +const animationDurationInSeconds = 12; + +export const HeroBanner = () => { + const arrayInChunks = splitToNChunks(icons, countIconGroups); + const gridSpan = 12 / countIconGroups; + + return ( + <Box className={classes.bannerContainer} bg="dark.6" pos="relative"> + <Stack gap={0}> + <Title order={2} c="dimmed"> + Welcome back to your + + + + Homarr Dashboard + + + + + {Array(countIconGroups) + .fill(0) + .map((_, columnIndex) => ( + + + {arrayInChunks[columnIndex]?.map((icon, index) => ( + + ))} + + {/* This is used for making the animation seem seamless */} + {arrayInChunks[columnIndex]?.map((icon, index) => ( + + ))} + + + ))} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/about/about.module.css b/apps/nextjs/src/app/[locale]/manage/about/about.module.css new file mode 100644 index 000000000..c1d362c48 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/about/about.module.css @@ -0,0 +1,3 @@ +.contributorCard { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); +} diff --git a/apps/nextjs/src/app/[locale]/manage/about/page.tsx b/apps/nextjs/src/app/[locale]/manage/about/page.tsx new file mode 100644 index 000000000..a1dadf037 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/about/page.tsx @@ -0,0 +1,170 @@ +import Image from "next/image"; +import { + Accordion, + AccordionControl, + AccordionItem, + AccordionPanel, + AspectRatio, + Avatar, + Card, + Center, + Flex, + Group, + List, + ListItem, + Stack, + Text, + Title, +} from "@mantine/core"; +import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react"; +import { setStaticParamsLocale } from "next-international/server"; + +import { getScopedI18n, getStaticParams } from "@homarr/translation/server"; + +import { createMetaTitle } from "~/metadata"; +import { getPackageAttributesAsync } from "~/versions/package-reader"; +import contributorsData from "../../../../../../../static-data/contributors.json"; +import translatorsData from "../../../../../../../static-data/translators.json"; +import logo from "../../../../../public/logo/logo.png"; +import classes from "./about.module.css"; + +export async function generateMetadata() { + const t = await getScopedI18n("management"); + + return { + title: createMetaTitle(t("metaTitle")), + }; +} + +interface PageProps { + params: { + locale: string; + }; +} + +export default async function AboutPage({ params: { locale } }: PageProps) { + setStaticParamsLocale(locale); + const t = await getScopedI18n("management.page.about"); + const attributes = await getPackageAttributesAsync(); + return ( +
+
+ + + + + Homarr + + {t("version", { version: attributes.version })} + + +
+ {t("text")} + + + + }> + + {t("accordion.contributors.title")} + + {t("accordion.contributors.subtitle", { + count: contributorsData.length, + })} + + + + + + {contributorsData.map((contributor) => ( + + ))} + + + + + }> + + {t("accordion.translators.title")} + + {t("accordion.translators.subtitle", { + count: translatorsData.length, + })} + + + + + + {translatorsData.map((translator) => ( + + ))} + + + + + }> + + {t("accordion.libraries.title")} + + {t("accordion.libraries.subtitle", { + count: Object.keys(attributes.dependencies).length, + })} + + + + + + {Object.entries(attributes.dependencies) + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .map(([key, value]) => ( + + {value.includes("workspace:") ? ( + {key} + ) : ( + {key} + )} + + ))} + + + + +
+ ); +} + +interface GenericContributorLinkCardProps { + name: string; + link: string; + image: string; +} + +const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLinkCardProps) => { + return ( + + + + + + {name} + + + + + ); +}; + +export function generateStaticParams() { + return getStaticParams(); +} + +export const dynamic = "force-static"; diff --git a/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx b/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx new file mode 100644 index 000000000..0f81cde48 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useCallback } from "react"; +import { ActionIcon } from "@mantine/core"; +import { IconTrash } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useConfirmModal } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { revalidatePathActionAsync } from "../../../revalidatePathAction"; + +interface AppDeleteButtonProps { + app: RouterOutputs["app"]["all"][number]; +} + +export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => { + const t = useScopedI18n("app.page.delete"); + const { openConfirmModal } = useConfirmModal(); + const { mutate, isPending } = clientApi.app.delete.useMutation(); + + const onClick = useCallback(() => { + openConfirmModal({ + title: t("title"), + children: t("message", app), + onConfirm: () => { + mutate( + { id: app.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("notification.success.title"), + message: t("notification.success.message"), + }); + void revalidatePathActionAsync("/manage/apps"); + }, + onError: () => { + showErrorNotification({ + title: t("notification.error.title"), + message: t("notification.error.message"), + }); + }, + }, + ); + }, + }); + }, [app, mutate, t, openConfirmModal]); + + return ( + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/apps/_form.tsx b/apps/nextjs/src/app/[locale]/manage/apps/_form.tsx new file mode 100644 index 000000000..2cb6b6a47 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/apps/_form.tsx @@ -0,0 +1,55 @@ +"use client"; + +import Link from "next/link"; +import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core"; + +import { useZodForm } from "@homarr/form"; +import type { TranslationFunction } from "@homarr/translation"; +import { useI18n } from "@homarr/translation/client"; +import type { z } from "@homarr/validation"; +import { validation } from "@homarr/validation"; + +import { IconPicker } from "~/components/icons/picker/icon-picker"; + +type FormType = z.infer; + +interface AppFormProps { + submitButtonTranslation: (t: TranslationFunction) => string; + initialValues?: FormType; + handleSubmit: (values: FormType) => void; + isPending: boolean; +} + +export const AppForm = (props: AppFormProps) => { + const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props; + const t = useI18n(); + + const form = useZodForm(validation.app.manage, { + initialValues: initialValues ?? { + name: "", + description: "", + iconUrl: "", + href: "", + }, + }); + + return ( + + + + +