Compare commits

..
1088 changed files with 30956 additions and 118651 deletions

No files matched your search

-191
View File
@@ -1,191 +0,0 @@
---
description: Comprehensive Homarr codebase architecture and conventions reference
alwaysApply: true
---
# Homarr Codebase Context
Homarr is an open-source, self-hosted dashboard for managing homelab services. It integrates with 50+ self-hosted apps (media servers, download clients, DNS, NAS, etc.) and provides a drag-and-drop widget-based UI.
## Tech Stack
- **Monorepo**: pnpm workspaces + Turborepo
- **Framework**: Next.js (App Router, `output: "standalone"`) — T3 Stack
- **Language**: TypeScript throughout
- **ORM**: Drizzle (supports SQLite via better-sqlite3, MySQL, PostgreSQL)
- **API**: tRPC (HTTP + WebSocket subscriptions) with superjson + OpenAPI bridge via `trpc-to-openapi`
- **Auth**: NextAuth v5 (database sessions, Credentials/LDAP/OIDC providers)
- **UI**: Mantine v7 (not Tailwind) + Tabler icons
- **State**: Jotai (atoms), TanStack Query (server state via tRPC)
- **Realtime**: WebSocket server (`ws`) for tRPC subscriptions, backed by Redis pub/sub
- **Cron**: `node-cron` in a standalone Fastify service (`apps/tasks`)
- **i18n**: next-intl with `[locale]` dynamic segment (prefix mode: `"never"`, locale from cookie)
- **Testing**: Vitest + jsdom, Playwright for E2E
- **Package manager**: pnpm 10.33.2, Node >= 24.15.0
## Repository Structure
```
homarr/
├── apps/
│ ├── nextjs/ # Main Next.js application (port 3000)
│ ├── tasks/ # Cron job runner + Fastify tRPC API (port 3002)
│ └── websocket/ # Standalone tRPC WebSocket server (port 3001)
├── packages/
│ ├── api/ # tRPC appRouter, procedures, OpenAPI
│ ├── auth/ # NextAuth config, providers, session, API keys
│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries
│ ├── core/ # Env validation, DB/Redis driver factories, logging
│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions
│ ├── widgets/ # All 39 dashboard widgets (definitions + components)
│ ├── integrations/ # Integration classes (HTTP clients to external apps)
│ ├── redis/ # Redis pub/sub channels, caching abstractions
│ ├── translation/ # next-intl setup, locale configs, lang JSON files
│ ├── ui/ # Shared Mantine components, theme, hooks
│ ├── validation/ # Shared zod schemas for API/forms
│ ├── common/ # Shared utilities, IDs, errors
│ ├── cron-jobs/ # Cron job implementations (25+ jobs)
│ ├── cron-jobs-core/ # Cron scheduling primitives
│ ├── cron-job-api/ # tRPC router for job management (start/stop/trigger)
│ ├── cron-job-status/ # Cron status via Redis
│ ├── boards/ # Board context, edit mode, cache updater
│ ├── modals/ # Modal primitives on Mantine
│ ├── modals-collection/ # Feature modals (apps, boards, docker, etc.)
│ ├── form/ # useZodForm (Mantine + zod resolver)
│ ├── forms-collection/# Reusable form UIs (new app, icon picker, upload)
│ ├── spotlight/ # Command palette / search with multiple modes
│ ├── request-handler/ # Server request handlers (feeds, integrations)
│ ├── notifications/ # Mantine notifications wrapper
│ ├── docker/ # Dockerode-based Docker access
│ ├── icons/ # Icon DB/repo integration
│ ├── image-proxy/ # Image proxy + caching
│ ├── ping/ # Reachability / ping utilities
│ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context
│ ├── old-import/ # Legacy Homarr import
│ ├── old-schema/ # Legacy Homarr zod schemas
│ └── cli/ # Node CLI for ops (brocli)
├── tooling/
│ ├── eslint/ # Shared ESLint configs
│ ├── prettier/ # Shared Prettier config
│ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build
```
## Three Runtime Services
| Service | Port | Role |
|---------|------|------|
| Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify |
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
## Package Dependency Layers
1. **Foundation**: `core` (env, DB drivers, Redis client, logging) → no `@homarr` deps
2. **Domain**: `definitions` → `common` → `core`
3. **Data**: `db` → `core`, `definitions`, `common`
4. **Auth**: `auth` → `db`, `definitions`, `core`, `validation`
5. **Cross-cutting**: `translation`, `validation`, `redis`, `server-settings`
6. **Feature backends**: `integrations`, `request-handler`, `cron-jobs`, `docker`, `ping`
7. **API surface**: `api` → pulls in auth, db, redis, integrations, etc.
8. **UI stack**: `ui`, `modals`, `form`, `notifications`, `boards`, `spotlight`
9. **Composed features**: `widgets`, `modals-collection`, `forms-collection`
## Key Patterns
### tRPC
- **Server caller** (RSC): `import { api } from "@homarr/api/server"` → cached caller with auth context
- **Client hooks**: `import { clientApi } from "@homarr/api/client"` → React Query hooks
- **Router structure**: `packages/api/src/root.ts` defines top-level namespaces (`user`, `board`, `widget`, `integration`, `docker`, `kubernetes`, etc.)
- **Procedures**: `publicProcedure`, `protectedProcedure`, `permissionRequiredProcedure`, `onboardingProcedure`
- **WebSocket subscriptions**: Client uses `wsLink` when `type === "subscription"`, connects to port 3001
### Multi-Database Support
Drizzle schemas exist in three parallel implementations:
- `packages/db/schema/sqlite.ts`
- `packages/db/schema/mysql.ts`
- `packages/db/schema/postgresql.ts`
Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-postgres`). Migrations are per-engine in `packages/db/migrations/{sqlite,mysql,postgresql}/`.
### Widget System
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
- Loads dynamically via `next/dynamic`
- Fetches data via tRPC client hooks (backed by cron job caches)
### Integration System
Integrations are defined in `packages/definitions/src/integration.ts` (`integrationDefs`) and instantiated via `createIntegrationAsync` in `packages/integrations/src/base/creator.ts`. Each extends abstract `Integration` class with `testConnectionAsync` and typed methods.
### Cron Job System
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
- Uses `createCronJob` with a cron expression
- Publishes results to Redis channels
- Widget subscriptions consume those channels via tRPC subscriptions
- Job management (start/stop/trigger) via `cronJobApi` HTTP client to port 3002
### Modals
Created with `createModal(component).withOptions({ defaultTitle })` from `@homarr/modals`. Opened via `useModalAction(SomeModal)`. Feature modals live in `packages/modals-collection/`.
### Forms
`useZodForm` from `@homarr/form` wraps Mantine `useForm` with zod validation. Shared form UIs in `packages/forms-collection/`.
### Routing (App Router)
- `apps/nextjs/src/app/[locale]/` — all UI routes under locale segment
- Route groups: `(home)`, `(content)`, `(board)` — don't affect URLs
- Key routes: `boards/[name]`, `manage/...` (admin), `auth/login`, `init` (onboarding), `widgets/[kind]`
- Locale driven by cookie (no URL prefix)
### Auth Providers
Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `oidc`. Database sessions with httpOnly cookies. API key auth for programmatic access.
## Environment Variables
Key env vars (from `.env.example`):
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
- `DB_URL`: Database connection string
- `AUTH_SECRET`: NextAuth secret
- `SECRET_ENCRYPTION_KEY`: AES-256-CBC for integration secrets
- `AUTH_PROVIDERS`: Comma-separated (`credentials`, `ldap`, `oidc`)
- `CRON_JOB_API_KEY`: Shared key between Next.js and tasks service
- `REDIS_*`: Redis connection (host, port, password)
- `ENABLE_KUBERNETES`: Optional Kubernetes integration
## Development Commands
- `pnpm dev` — runs all three services in parallel via Turborepo
- `pnpm docker:dev` — starts Redis + MySQL + PostgreSQL containers
- `pnpm db:push` — push schema to SQLite (dev)
- `pnpm db:studio` — Drizzle Studio
- `pnpm test` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E
- `pnpm lint` / `pnpm format` — ESLint + Prettier
- `pnpm typecheck` — tsc --noEmit across all packages
## Conventions
- Workspace packages use `@homarr/` scope
- All versions managed via pnpm `catalog:` in workspace
- Path alias `~/*` → `./src/*` in Next.js app
- Mantine for all UI (no Tailwind) — primary color is red, `autoContrast: true`
- Icons from `@tabler/icons-react`
- Drag-and-drop via `@dnd-kit/*`
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
+6 -3
View File
@@ -3,6 +3,9 @@ version = 1
[[analyzers]] [[analyzers]]
name = "javascript" name = "javascript"
[analyzers.meta] [analyzers.meta]
plugins = ["react"] plugins = ["react"]
environment = ["nodejs"] environment = ["nodejs"]
[[transformers]]
name = "prettier"
+1 -6
View File
@@ -5,12 +5,7 @@ node_modules
npm-debug.log npm-debug.log
README.md README.md
.next .next
.turbo
.git .git
dev dev
.build .build
e2e e2e
apps/nextjs/.next
.env
.env.*
!.env.example
+2 -24
View File
@@ -30,33 +30,11 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password' # DB_PASSWORD='password'
# DB_NAME='name-of-database' # DB_NAME='name-of-database'
# The following is an example on how to use the node-postgres driver: # The below path can be used to store trusted certificates during development, it is not required and can be left empty.
# DB_DRIVER='node-postgres'
# DB_URL='postgres://user:password@host:port/database'
# The below path can be used to store trusted certificates, it is not required and can be left empty.
# If it is empty, it will default to `/appdata/trusted-certificates` in production.
# If it is used, please use the full path to the directory where the certificates are stored. # If it is used, please use the full path to the directory where the certificates are stored.
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES' # LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
TURBO_TELEMETRY_DISABLED=1 TURBO_TELEMETRY_DISABLED=1
# Turborepo remote cache (optional, for faster CI/local builds)
# Uncomment and fill in to enable remote caching locally.
# CI uses GitHub secrets for these values automatically.
# TURBO_API=https://your-cache-worker.workers.dev
# TURBO_TEAM=team_your_team
# TURBO_TOKEN=your-turbo-token
# TURBO_REMOTE_CACHE_SIGNATURE_KEY=your-signature-key
# Comma-separated paths to Docker-compatible Unix sockets.
# Useful for connecting to multiple runtimes (Docker + Podman) simultaneously.
# When unset and no TCP hosts configured, defaults to /var/run/docker.sock.
# Example: DOCKER_SOCKET_PATHS=/var/run/docker.sock,/run/user/1000/podman/podman.sock
# DOCKER_SOCKET_PATHS=
# Enable kubernetes tool # Enable kubernetes tool
# ENABLE_KUBERNETES=true # ENABLE_KUBERNETES=true
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true
-62
View File
@@ -31,68 +31,6 @@ body:
label: Version label: Version
description: What version of Homarr are you running? description: What version of Homarr are you running?
options: options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.62.0
- 1.61.0
- 1.60.1-beta.1
- 1.60.0
- 1.59.3
- 1.59.2
- 1.59.1
- 1.59.0
- 1.58.1
- 1.58.0
- 1.57.1
- 1.57.0
- 1.56.1
- 1.56.0
- 1.55.0
- 1.54.0
- 1.53.0
- 1.52.0
- 1.51.0
- 1.50.1
- 1.50.0
- 1.49.1
- 1.49.0
- 1.48.0
- 1.47.0
- 1.46.0
- 1.45.3
- 1.45.2
- 1.45.1
- 1.45.0
- 1.44.0
- 1.43.1
- 1.43.0
- 1.42.1
- 1.42.0
- 1.41.0
- 1.40.0
- 1.39.0
- 1.38.0
- 1.37.0
- 1.36.1
- 1.36.0
- 1.35.1
- 1.35.0
- 1.34.0
- 1.33.0
- 1.32.0
- 1.31.0
- 1.30.1
- 1.30.0
- 1.29.0
- 1.28.1
- 1.28.0
- 1.27.0
- 1.26.0
- 1.25.0
- 1.24.0
- 1.23.0
- 1.22.0
- 1.21.0
- 1.20.0 - 1.20.0
- 1.19.1 - 1.19.1
- 1.19.0 - 1.19.0
+5 -5
View File
@@ -25,16 +25,16 @@ body:
label: Public API available? label: Public API available?
description: Is there a public API available, that we can consume in Homarr? description: Is there a public API available, that we can consume in Homarr?
options: options:
- "Yes, available on a website" - Yes, available on a website
- "Yes, available in the application itself" - Yes, available in the application itself
- "No" - No
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
attributes: attributes:
label: Are you willing to contribute this yourself? label: Are you willing to contribute this yourself?
options: options:
- "Yes" - Yes
- "No" - No
validations: validations:
required: true required: true
+2 -2
View File
@@ -24,7 +24,7 @@ body:
attributes: attributes:
label: Are you willing to contribute this yourself? label: Are you willing to contribute this yourself?
options: options:
- "Yes" - Yes
- "No" - No
validations: validations:
required: true required: true
@@ -1,64 +0,0 @@
name: Extract Build Artifact
description: Extracts artifacts from an existing Docker image to be used for from source installation
inputs:
digest:
description: Digest of Docker image to use
required: true
architecture:
description: Name of architecture, will be used to build artifact content (amd64 or arm64)
required: true
release-tag:
description: Tag of the release to which the artifact will be attached
required: true
repository:
description: Repository to which the release belongs, e.g. owner/repo
required: true
token:
description: GitHub token with permissions to upload release assets
required: true
runs:
using: "composite"
steps:
- name: Prebuilt debian dependencies
uses: homarr-labs/homarr/.github/actions/prebuilt-debian@dev
id: prebuilt-debian
with:
architecture: ${{ inputs.architecture }}
- name: Start docker container for ${{ inputs.architecture }}
run: |
docker run --name homarr \
-e "SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000" \
--detach --rm ${{ inputs.digest }}
shell: bash
- name: Prepare extraction
run: |
docker exec homarr cp /etc/nginx/templates/nginx.conf /app && \
mkdir -p ${{ runner.temp }}/extraction/${{ inputs.architecture }}
shell: bash
- name: Extract source from ${{ inputs.architecture }} container (alpine)
run: |
docker exec homarr tar -czf extraction-alpine.tar.gz -C /app . && \
docker cp homarr:/app/extraction-alpine.tar.gz ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-alpine-${{ inputs.architecture }}.tar.gz && \
docker exec homarr rm /app/extraction-alpine.tar.gz
shell: bash
- name: Extract source from ${{ inputs.architecture }} container (debian)
run: |
docker cp ${{ steps.prebuilt-debian.outputs.path }}/. homarr:/app/build && \
docker cp ${{ steps.prebuilt-debian.outputs.path }}/. homarr:/app/node_modules/better-sqlite3/build/Release && \
docker exec homarr tar -czf extraction-debian.tar.gz -C /app . && \
docker cp homarr:/app/extraction-debian.tar.gz ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-debian-${{ inputs.architecture }}.tar.gz
shell: bash
- name: Stop ${{ inputs.architecture }} container
if: always()
run: docker container remove --force --volumes homarr
shell: bash
- name: Add build archive to release (alpine)
env:
GH_TOKEN: ${{ inputs.token }}
run: gh release upload --repo ${{ inputs.repository }} ${{ inputs.release-tag }} ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-alpine-${{ inputs.architecture }}.tar.gz --clobber
shell: bash
- name: Add build archive to release (debian)
env:
GH_TOKEN: ${{ inputs.token }}
run: gh release upload --repo ${{ inputs.repository }} ${{ inputs.release-tag }} ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-debian-${{ inputs.architecture }}.tar.gz --clobber
shell: bash
@@ -1,42 +0,0 @@
name: Prebuilt dependencies for debian
description: Provides prebuilt dependencies for debian based docker images
inputs:
architecture:
description: Name of architecture, will be used to build docker image (e.g. amd64, arm64)
required: true
outputs:
path:
description: Path to extracted prebuilt dependencies
value: ${{ runner.temp }}/prebuilts
runs:
using: "composite"
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build docker image for ${{ inputs.architecture }}
id: build
uses: docker/build-push-action@v7
with:
push: false
load: true
context: ./deployments/prebuilt-debian
platforms: linux/${{ inputs.architecture }}
tags: prebuilt-debian
- name: Start docker container for ${{ inputs.architecture }}
run: |
docker run --name prebuilt-debian \
--detach --rm prebuilt-debian
shell: bash
- name: Extract prebuilt dependencies from ${{ inputs.architecture }} container
run: |
mkdir -p ${{ runner.temp }}/prebuilts && \
docker cp prebuilt-debian:/app/node_modules/better-sqlite3/build/Release/better_sqlite3.node ${{ runner.temp }}/prebuilts/better_sqlite3.node
shell: bash
- name: Stop ${{ inputs.architecture }} container
if: always()
run: docker container remove --force --volumes prebuilt-debian
shell: bash
+4 -4
View File
@@ -6,8 +6,8 @@
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:** **Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
- [ ] Builds without warnings or errors (`pnpm build`, autofix with `pnpm format:fix`) - [ ] Builds without warnings or errors (``pnpm build``, autofix with ``pnpm format:fix``)
- [ ] Pull request targets `dev` branch - [ ] Pull request targets ``dev`` branch
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] 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) - [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
+1 -35
View File
@@ -3,51 +3,17 @@
extends: ["config:recommended"], extends: ["config:recommended"],
packageRules: [ packageRules: [
{ {
matchPackageNames: ["^@homarr/"], matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
// Reenable once https://github.com/privatenumber/tsx/issues/737 is fixed
{
matchPackageNames: ["tsx"],
enabled: false,
},
// Reenable once bug with undici v8 is fixed (https://github.com/homarr-labs/homarr/issues/5467)
{
matchPackageNames: ["undici"],
matchNewValue: "8.*",
enabled: false, enabled: false,
}, },
{ {
matchUpdateTypes: ["minor", "patch", "pin", "digest"], matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true, automerge: true,
}, },
{
// disallow automerge for onlyBuiltDependencies
matchPackageNames: [
"@tree-sitter-grammars/tree-sitter-yaml",
"bcrypt",
"better-sqlite3",
"cpu-features",
"esbuild",
"sharp",
"ssh2",
"tree-sitter",
"tree-sitter-json",
],
automerge: false,
},
], ],
// Updates lockfile for prebuilt-debian after if package.json is updated, otherwise only root lockfile is updated
postUpgradeTasks: {
commands: ["cd deployments/prebuilt-debian && pnpm install --ignore-workspace --lockfile-only"],
fileFilters: ["deployments/prebuilt-debian/pnpm-lock.yaml"],
executionMode: "update",
},
updateInternalDeps: true, updateInternalDeps: true,
rangeStrategy: "bump", rangeStrategy: "bump",
automerge: false, automerge: false,
baseBranches: ["dev"], baseBranches: ["dev"],
dependencyDashboard: false, dependencyDashboard: false,
// prevent special characters in branch names (otherwise git errors occur on windows)
branchNameStrict: true,
} }
+5 -15
View File
@@ -3,29 +3,19 @@ on:
pull_request: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
permissions: {}
jobs: jobs:
approve-automatic-prs: approve-automatic-prs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 2 if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D and https://api.github.com/users/homarr-update-contributors%5Bbot%5D
# 158783068: homarr-renovate[bot]
# 190541745: homarr-crowdin[bot]
# 210161987: homarr-update-contributors[bot]
# 175486441: homarr-releases[bot]
# Extracted from https://api.github.com/users/<username>
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: actions/create-github-app-token@v3 uses: tibdex/github-app-token@v2
with: with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to approve pull request
- name: Install GitHub CLI - name: Install GitHub CLI
run: sudo apt-get install -y gh run: sudo apt-get install -y gh
- name: Approve automatic PRs - name: Approve automatic PRs
+16 -71
View File
@@ -1,6 +1,4 @@
name: "[Quality] Code Analysis" name: "[Quality] Code Analysis"
permissions:
contents: read
on: on:
pull_request: pull_request:
@@ -11,58 +9,20 @@ on:
concurrency: concurrency:
group: "${{ github.workflow }}-${{ github.ref }}" group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
env: env:
FORCE_COLOR: 3 FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} TURBO_TEAM: ${{ vars.TURBO_TEAM }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
jobs: jobs:
lint-format-autofix:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
run: |
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "style: auto-fix lint and format"
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
@@ -72,41 +32,34 @@ jobs:
run: cp .env.example .env run: cp .env.example .env
- name: Lint - name: Lint
run: pnpm turbo lint --affected && pnpm lint:ws run: pnpm lint && pnpm lint:ws
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Format - name: Format
run: pnpm turbo format --affected --continue run: pnpm format
typecheck: typecheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Typecheck - name: Typecheck
run: pnpm turbo typecheck --affected run: turbo typecheck
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
@@ -122,25 +75,20 @@ jobs:
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build docker image - name: Build docker image
id: build-docker-image id: build-docker-image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64 platforms: linux/amd64
context: . context: .
push: false push: false
load: true
tags: homarr-e2e tags: homarr-e2e
network: host network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
- name: Install playwright browsers - name: Install playwright browsers
@@ -151,15 +99,12 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Copy env - name: Copy env
shell: bash shell: bash
run: cp .env.example .env run: cp .env.example .env
- name: Build - name: Build
run: pnpm turbo build --affected run: pnpm build
@@ -0,0 +1,14 @@
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
name: "[Conventions] Semantic Commits"
on:
pull_request:
branches: [ dev ]
jobs:
build:
name: Conventional Commits
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: webiny/action-conventional-commits@v1.3.0
@@ -13,8 +13,7 @@ permissions:
jobs: jobs:
validate-pull-request-title: validate-pull-request-title:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 1
steps: steps:
- uses: amannn/action-semantic-pull-request@v6 - uses: amannn/action-semantic-pull-request@v5
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -5,26 +5,20 @@ on:
schedule: schedule:
- cron: "0 0 * * *" # every day at midnight - cron: "0 0 * * *" # every day at midnight
permissions:
contents: write # required to push changes
jobs: jobs:
download-crowdin-translations: download-crowdin-translations:
timeout-minutes: 5
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: actions/create-github-app-token@v3 uses: tibdex/github-app-token@v2
with: with:
private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }} private_key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
client-id: ${{ secrets.CROWDIN_APP_ID }} app_id: ${{ secrets.CROWDIN_APP_ID }}
permission-contents: write # required to commit to crowdin branch
permission-pull-requests: write # required to create pull request
- name: Download Crowdin translations - name: Download Crowdin translations
id: crowdin-download id: crowdin-download
+1 -4
View File
@@ -1,6 +1,4 @@
name: "[Crowdin] Upload translations" name: "[Crowdin] Upload translations"
permissions:
contents: read
on: on:
workflow_dispatch: workflow_dispatch:
@@ -14,11 +12,10 @@ jobs:
# Don't run this action if the downloaded translations are being pushed # Don't run this action if the downloaded translations are being pushed
if: "!contains(github.event.head_commit.message, 'chore(lang)')" if: "!contains(github.event.head_commit.message, 'chore(lang)')"
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Upload Crowdin translations - name: Upload Crowdin translations
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2
@@ -1,104 +0,0 @@
name: "[Deployment] Beta Release"
on:
workflow_dispatch:
inputs:
send-notifications:
type: boolean
required: false
default: true
description: Send notifications
permissions:
contents: write
pull-requests: write
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: next
- name: Create pull request
id: create-pull-request
run: 'gh pr create --title "chore(release): release ${{ steps.semver.outputs.next }}-beta" --body "Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}-beta``" --base beta --head next --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }}-beta (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token
id: obtainApprovalToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write
- name: Approve PR
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # write to beta branch (due to merge)
permission-pull-requests: write # merge pull request
- id: automerge
if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge
uses: "pascalgn/automerge-action@v0.16.4"
env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
MERGE_METHOD: merge # we prefer merge commits for merging to beta
MERGE_COMMIT_MESSAGE: "chore(release): release ${{ steps.semver.outputs.next }}-beta"
MERGE_DELETE_BRANCH: false # never set to true!
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails
MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Discord Fail Notification
if: failure() && github.event.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "The beta release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
+27 -83
View File
@@ -5,7 +5,6 @@ on:
branches: branches:
- main - main
- beta - beta
- next
- dev - dev
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -33,13 +32,12 @@ jobs:
release: release:
name: Create tag and release name: Create tag and release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5
env: env:
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' || github.ref_name == 'next' }} SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }}
outputs: outputs:
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }} version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }} git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
skipped: ${{ env.SKIP_RELEASE }}
steps: steps:
- run: echo "Skipping release for workflow_dispatch event" - run: echo "Skipping release for workflow_dispatch event"
if: env.SKIP_RELEASE == 'true' if: env.SKIP_RELEASE == 'true'
@@ -51,23 +49,18 @@ jobs:
- name: Obtain token - name: Obtain token
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
id: obtainToken id: obtainToken
uses: actions/create-github-app-token@v3 uses: tibdex/github-app-token@v2
with: with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release - uses: actions/checkout@v4
- uses: actions/checkout@v6
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
with: with:
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@v6 - uses: actions/setup-node@v4
if: env.SKIP_RELEASE == 'false'
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
with: with:
node-version: 24.16.0 node-version: 22
package-manager-cache: false
- run: npm i -g pnpm - run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
- name: Install dependencies - name: Install dependencies
@@ -96,52 +89,50 @@ jobs:
id: read-git-ref id: read-git-ref
run: | run: |
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Update dev / next branch - name: Update dev branch
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
continue-on-error: true # Prevent pipeline from failing when merge fails continue-on-error: true # Prevent pipeline from failing when merge fails
env: env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
UPSTREAM_BRANCH: ${{ github.ref_name == 'main' && 'dev' || 'next' }}
run: | run: |
git config user.name "Releases Homarr" git config user.name "Releases Homarr"
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com" git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
git fetch origin ${{ env.UPSTREAM_BRANCH }} git fetch origin dev
git checkout ${{ env.UPSTREAM_BRANCH }} git checkout dev
git pull origin ${{ env.UPSTREAM_BRANCH }} git pull origin dev
git rebase ${{ github.ref_name }} git rebase ${{ github.ref_name }}
git push origin ${{ env.UPSTREAM_BRANCH }} git push origin dev
build-amd64: build-amd64:
name: Build docker image for amd64 name: Build docker image for amd64
needs: release needs: release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
outputs: outputs:
digest: ${{ steps.build.outputs.digest }} digest: ${{ steps.build.outputs.digest }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
ref: ${{ needs.release.outputs.git_ref }} ref: ${{ needs.release.outputs.git_ref }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: "${{ env.GHCR_REPO }}" images: "${{ env.GHCR_REPO }}"
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
network: host network: host
@@ -150,37 +141,37 @@ jobs:
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
env: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
build-arm64: build-arm64:
name: Build docker image for arm64 name: Build docker image for arm64
needs: release needs: release
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
timeout-minutes: 20
outputs: outputs:
digest: ${{ steps.build.outputs.digest }} digest: ${{ steps.build.outputs.digest }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
ref: ${{ needs.release.outputs.git_ref }} ref: ${{ needs.release.outputs.git_ref }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: "${{ env.GHCR_REPO }}" images: "${{ env.GHCR_REPO }}"
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
network: host network: host
@@ -189,58 +180,18 @@ jobs:
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
env: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
extract-asset-amd64:
name: Extract amd64 asset from docker image
needs: [release, build-amd64]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Extract amd64
if: needs.release.outputs.skipped == 'false'
uses: homarr-labs/homarr/.github/actions/extract-build-artifact@dev
with:
digest: "${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }}"
architecture: amd64
release-tag: ${{ needs.release.outputs.version }}
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
extract-asset-arm64:
name: Extract arm64 asset from docker image
needs: [release, build-arm64]
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- name: Extract arm64
if: needs.release.outputs.skipped == 'false'
uses: homarr-labs/homarr/.github/actions/extract-build-artifact@dev
with:
digest: "${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}"
architecture: arm64
release-tag: ${{ needs.release.outputs.version }}
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
publish: publish:
name: Complete deployment and notify name: Complete deployment and notify
needs: [release, build-amd64, build-arm64, extract-asset-amd64, extract-asset-arm64] needs: [release, build-amd64, build-arm64]
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5
env: env:
NEXT_VERSION: ${{ needs.release.outputs.version }} NEXT_VERSION: ${{ needs.release.outputs.version }}
DEPLOY_LATEST: ${{ github.ref_name == 'main' }} DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
DEPLOY_BETA: ${{ github.ref_name == 'beta' }} DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
steps: steps:
- name: Obtain token
if: needs.release.outputs.skipped == 'false'
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to publish the release
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -266,16 +217,9 @@ jobs:
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \ ${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }} ${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
- name: Publish release
if: needs.release.outputs.skipped == 'false'
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh release edit --repo ${{ github.repository }} ${{ needs.release.outputs.version }} --draft=false
- name: Discord notification - name: Discord notification
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0 uses: Ilshidur/action-discord@master
with: with:
args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**." args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**."
+18 -23
View File
@@ -18,17 +18,16 @@ permissions:
jobs: jobs:
create-and-merge-pr: create-and-merge-pr:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 2
steps: steps:
- name: Discord notification - name: Discord notification
if: ${{ github.event.inputs.send-notifications }} if: ${{ github.events.inputs.send-notifications }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0 uses: Ilshidur/action-discord@master
with: with:
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Get Next Version - name: Get Next Version
@@ -38,24 +37,22 @@ jobs:
token: ${{ github.token }} token: ${{ github.token }}
branch: dev branch: dev
- name: Create pull request - name: Create pull request
id: create-pull-request
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge' run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge'
env: env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification - name: Discord notification
if: ${{ github.event.inputs.send-notifications }} if: ${{ github.events.inputs.send-notifications }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0 uses: Ilshidur/action-discord@master
with: with:
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})" args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token - name: Obtain token
id: obtainApprovalToken id: obtainApprovalToken
uses: actions/create-github-app-token@v3 uses: tibdex/github-app-token@v2
with: with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write
- name: Approve PR - name: Approve PR
env: env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
@@ -63,19 +60,17 @@ jobs:
gh pr review --approve --body "Automatically approved by GitHub Action" gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token - name: Obtain token
id: obtainMergeToken id: obtainMergeToken
uses: actions/create-github-app-token@v3 uses: tibdex/github-app-token@v2
with: with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # write to main branch (due to merge)
permission-pull-requests: write # merge pull request
- id: automerge - id: automerge
if: ${{ steps.semver.outputs.bump != 'major' }} if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge name: automerge
uses: "pascalgn/automerge-action@v0.16.4" uses: "pascalgn/automerge-action@v0.16.4"
env: env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
MERGE_METHOD: merge # we prefer merge commits for merging to main MERGE_METHOD: merge # we prefer merge commits for merging to master
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}" MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
MERGE_DELETE_BRANCH: false # never set to true! MERGE_DELETE_BRANCH: false # never set to true!
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}" PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
@@ -84,23 +79,23 @@ jobs:
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification - name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }} if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0 uses: Ilshidur/action-discord@master
with: with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}" args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Major Bump Discord notification - name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }} if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0 uses: Ilshidur/action-discord@master
with: with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}" args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Discord Fail Notification - name: Discord Fail Notification
if: failure() && github.event.inputs.send-notifications if: failure() && github.events.inputs.send-notifications
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0 uses: Ilshidur/action-discord@master
with: with:
args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed" args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
@@ -1,45 +0,0 @@
name: "[Deployments] Validate prebuilt debian dependencies"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
paths: [".github/actions/prebuilt-debian/**", "deployments/prebuilt-debian/**"]
jobs:
prebuilt-debian-validate-amd64:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Validate prebuilt dependencies for amd64
id: validate-amd64
uses: ./.github/actions/prebuilt-debian
with:
architecture: amd64
- name: Check extracted files for amd64
run: |
if [ ! -f "${{ steps.validate-amd64.outputs.path }}/better_sqlite3.node" ]; then
echo "better_sqlite3.node not found for amd64!"
exit 1
fi
prebuilt-debian-validate-arm64:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Validate prebuilt dependencies for arm64
id: validate-arm64
uses: ./.github/actions/prebuilt-debian
with:
architecture: arm64
- name: Check extracted files for arm64
run: |
if [ ! -f "${{ steps.validate-arm64.outputs.path }}/better_sqlite3.node" ]; then
echo "better_sqlite3.node not found for arm64!"
exit 1
fi
@@ -1,19 +0,0 @@
name: "[Renovate] Validate configuration"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
paths: [".github/renovate.json5"]
jobs:
renovate-validate:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@v6
- run: |
npx --yes --package renovate -- \
renovate-config-validator --strict .github/renovate.json5
-87
View File
@@ -1,87 +0,0 @@
permissions: {}
on:
release:
types: [released]
jobs:
trigger-docs-release:
name: Trigger Documentation Release
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }}
client-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
owner: homarr-labs
repositories: |
documentation
permission-contents: write # required to dispatch repository workflow
- name: Trigger documentation release
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
SOURCE_TAG: ${{ github.event.release.tag_name }}
run: |
curl -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/homarr-labs/documentation/dispatches \
-d @- <<EOF
{
"event_type": "trigger-release",
"client_payload": {
"tag": "${SOURCE_TAG}"
}
}
EOF
update-bug-report-template:
name: Update Bug Report Template
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v6
with:
token: ${{ steps.obtainToken.outputs.token }}
ref: dev
- name: Setup
uses: ./tooling/github/setup
- run: pnpm run scripts:update-bug-report-template
env:
NEXT_VERSION: ${{ github.event.release.tag_name }}
- name: Create Pull Request
id: create-pull-request
uses: peter-evans/create-pull-request@v8
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
commit-message: "chore: update bug report template"
author: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
token: ${{ steps.obtainToken.outputs.token }}
branch: update-bug-report-template
base: dev
title: "chore: update bug report template"
delete-branch: true
body: |
This PR automatically updates the bug report template of Homarr in the .github/ISSUE_TEMPLATE/bug_report.yml
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Enable auto-merge
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash
@@ -1,27 +0,0 @@
permissions:
statuses: write
on:
pull_request:
types: [opened, synchronize]
jobs:
skip-stability-days:
if: ${{ !startsWith(github.head_ref, 'renovate/') && github.event.pull_request.head.repo.full_name == github.repository }}
name: Skip Stability Days
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- name: Add status check
env:
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
run: |
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
-f 'state=success' \
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
-f 'description=Skipped for non renovate branches' \
-f 'context=renovate/stability-days'
+8 -12
View File
@@ -10,35 +10,31 @@ env:
CROWDIN_TOKEN: "${{ secrets.CROWDIN_UPDATE_CONTRIBUTORS_TOKEN }}" CROWDIN_TOKEN: "${{ secrets.CROWDIN_UPDATE_CONTRIBUTORS_TOKEN }}"
permissions: permissions:
contents: read contents: write
jobs: jobs:
update-contributors: update-contributors:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 2
strategy: strategy:
matrix: matrix:
node-version: [22] node-version: [22]
steps: steps:
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: actions/create-github-app-token@v3 uses: tibdex/github-app-token@v2
with: with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }} private_key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }} app_id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
env: env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- uses: pnpm/action-setup@v6
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Run update script - name: Run update script
run: node ./scripts/update-contributors.mjs run: node ./scripts/update-contributors.mjs
@@ -54,7 +50,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
id: create-pull-request id: create-pull-request
uses: peter-evans/create-pull-request@v8 uses: peter-evans/create-pull-request@v7
with: with:
token: ${{ steps.obtainToken.outputs.token }} token: ${{ steps.obtainToken.outputs.token }}
branch: update-contributors branch: update-contributors
@@ -1,65 +0,0 @@
name: Update integration list
on:
workflow_dispatch: {}
push:
paths:
- packages/definitions/src/integration.ts
branches:
- dev
permissions:
pull-requests: write
jobs:
update-readme:
concurrency:
group: update-integration
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v6
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Setup
uses: ./tooling/github/setup
- run: pnpm run scripts:update-readme-integrations
- name: Commit changes
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
git config --global user.email "210161987+homarr-update-contributors[bot]@users.noreply.github.com"
git config --global user.name "Homarr Update Contributors"
git add .
git commit -m "chore: update integrations list readme"
- name: Create Pull Request
id: create-pull-request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ steps.obtainToken.outputs.token }}
branch: update-integrations-readme
base: dev
title: "chore: update integrations list readme"
delete-branch: true
body: |
This PR automatically updates the list of integrations of Homarr in the README.md
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Enable auto-merge
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash
+1 -5
View File
@@ -67,8 +67,4 @@ e2e/shared/tmp
apps/nextjs/public/images/background.png apps/nextjs/public/images/background.png
# next-intl # next-intl
en.d.json.ts en.d.json.ts
# personal agent config
AGENTS.md
.opencode
-1
View File
@@ -3,4 +3,3 @@
# @link https://docs.expo.dev/guides/monorepos/#common-issues # @link https://docs.expo.dev/guides/monorepos/#common-issues
node-linker=hoisted node-linker=hoisted
strict-peer-dependencies=false strict-peer-dependencies=false
engine-strict=true
+1 -1
View File
@@ -1 +1 @@
24.16.0 22.16.0
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 120,
"endOfLine": "lf",
"sortPackageJson": true,
"ignorePatterns": ["**/*.cjs", "**/lang/**", "static-data/**", "CHANGELOG.md"]
}
-37
View File
@@ -1,37 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "import", "jsx-a11y"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"rules": {
"eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"typescript/consistent-type-imports": ["warn", { "prefer": "type-imports", "fixStyle": "separate-type-imports" }],
"typescript/no-non-null-assertion": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"unicorn/no-new-array": "warn",
"unicorn/no-useless-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"jsx-a11y/prefer-tag-over-role": "warn",
"oxc/const-comparisons": "warn",
"react-hooks/exhaustive-deps": "warn",
"react/no-children-prop": "warn",
"jsx-a11y/no-autofocus": "warn",
"jsx-a11y/img-redundant-alt": "warn",
"react/react-in-jsx-scope": "off"
},
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", "**/lang/*.d.json.ts", ".next/**"],
"settings": {
"react": { "version": "19" }
}
}
-1
View File
@@ -44,7 +44,6 @@
{ {
"successComment": false, "successComment": false,
"failComment": false, "failComment": false,
"draftRelease": true,
"releaseBodyTemplate": "<%= _.truncate(nextRelease.notes, { 'length': 124000, 'omission': '' }) %>" "releaseBodyTemplate": "<%= _.truncate(nextRelease.notes, { 'length': 124000, 'omission': '' }) %>"
} }
] ]
@@ -1,13 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:migration:postgresql:generate" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:migration:postgresql:generate" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>
+2 -1
View File
@@ -31,5 +31,6 @@ scopeRangeRegex: "(?:const|let|var)\\s+t\\s*=\\s*(?:await\\s+)?(?:getScopedI18n|
# refactorTemplates: # refactorTemplates:
# - i18n.get("$1") # - i18n.get("$1")
# If set to true, only enables this custom framework (will disable all built-in frameworks) # If set to true, only enables this custom framework (will disable all built-in frameworks)
monopoly: true monopoly: true
+15 -5
View File
@@ -15,7 +15,9 @@
"request": "launch", "request": "launch",
"command": "pnpm docker:dev", "command": "pnpm docker:dev",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"] "skipFiles": [
"<node_internals>/**"
]
}, },
{ {
"name": "lint fix", "name": "lint fix",
@@ -23,7 +25,9 @@
"request": "launch", "request": "launch",
"command": "pnpm lint:fix", "command": "pnpm lint:fix",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"] "skipFiles": [
"<node_internals>/**"
]
}, },
{ {
"name": "format fix", "name": "format fix",
@@ -31,7 +35,9 @@
"request": "launch", "request": "launch",
"command": "pnpm format:fix", "command": "pnpm format:fix",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"] "skipFiles": [
"<node_internals>/**"
]
}, },
{ {
"name": "db push", "name": "db push",
@@ -63,7 +69,9 @@
"request": "launch", "request": "launch",
"command": "pnpm test", "command": "pnpm test",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"] "skipFiles": [
"<node_internals>/**"
]
}, },
{ {
"name": "test ui", "name": "test ui",
@@ -71,7 +79,9 @@
"request": "launch", "request": "launch",
"command": "pnpm test:ui", "command": "pnpm test:ui",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"skipFiles": ["<node_internals>/**"] "skipFiles": [
"<node_internals>/**"
]
} }
] ]
} }
+14 -3
View File
@@ -1,6 +1,13 @@
{ {
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"eslint.experimental.useFlatConfig": true,
"typescript.tsdk": "node_modules\\typescript\\lib", "typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true, "js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": [ "cSpell.words": [
"ajnart", "ajnart",
"cqmin", "cqmin",
@@ -27,7 +34,11 @@
], ],
"i18n-ally.dirStructure": "auto", "i18n-ally.dirStructure": "auto",
"i18n-ally.displayLanguage": "en", "i18n-ally.displayLanguage": "en",
"i18n-ally.enabledFrameworks": ["custom"], "i18n-ally.enabledFrameworks": [
"i18n-ally.localesPaths": ["packages/translation/src/lang"], "custom"
"i18n-ally.keystyle": "nested" ],
"i18n-ally.localesPaths": [
"packages/translation/src/lang",
],
"i18n-ally.keystyle": "nested",
} }
+2800 -4325
View File
File diff suppressed because it is too large. Load diff
-12
View File
@@ -1,13 +1 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers * @homarr-labs/maintainers
# Exempt Renovate‑managed files (no owners)
package.json
**/package.json
package-lock.json
**/package-lock.json
pnpm-lock.yaml
**/pnpm-lock.yaml
pnpm-workspace.yaml
Dockerfile
docker-compose.yml
+8 -4
View File
@@ -1,4 +1,4 @@
FROM node:24.16.0-alpine AS base FROM node:22.16.0-alpine AS base
FROM base AS builder FROM base AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -10,6 +10,8 @@ RUN apk add --no-cache libc6-compat curl bash
RUN apk update RUN apk update
COPY . . COPY . .
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build # Copy static data as it is not part of the build
@@ -17,7 +19,8 @@ COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true' ARG SKIP_ENV_VALIDATION='true'
ARG CI='true' ARG CI='true'
ARG DISABLE_REDIS_LOGS='true' ARG DISABLE_REDIS_LOGS='true'
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm build RUN corepack enable pnpm && pnpm build
FROM base AS runner FROM base AS runner
@@ -43,6 +46,8 @@ RUN mkdir -p /var/cache/nginx && \
COPY --from=builder /app/apps/nextjs/next.config.ts . COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json . COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder /app/packages/db/migrations ./db/migrations COPY --from=builder /app/packages/db/migrations ./db/migrations
@@ -53,7 +58,7 @@ COPY --from=builder /app/apps/nextjs/.next/standalone ./
COPY --from=builder /app/apps/nextjs/.next/static ./apps/nextjs/.next/static COPY --from=builder /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder /app/apps/nextjs/public ./apps/nextjs/public COPY --from=builder /app/apps/nextjs/public ./apps/nextjs/public
COPY scripts/run.sh ./run.sh COPY scripts/run.sh ./run.sh
COPY --chmod=755 scripts/entrypoint.sh ./entrypoint.sh COPY --chmod=777 scripts/entrypoint.sh ./entrypoint.sh
COPY packages/redis/redis.conf /app/redis.conf COPY packages/redis/redis.conf /app/redis.conf
COPY nginx.conf /etc/nginx/templates/nginx.conf COPY nginx.conf /etc/nginx/templates/nginx.conf
@@ -62,7 +67,6 @@ ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite' ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3' ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials' ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production' ENV NODE_ENV='production'
ENTRYPOINT [ "/app/entrypoint.sh" ] ENTRYPOINT [ "/app/entrypoint.sh" ]
+4 -4
View File
@@ -1,18 +1,18 @@
# Security Policy # Security Policy
This policy is relevant if you found potential vulnerabilities in an audit. This policy is relevant if you found potential vulnerabilities in an audit.
We consider something as a vulnerability if it... We consider something as a vulnerability if it...
1. puts users or user data at risk 1. puts users or user data at risk
2. enables third parties to gain control or access (e.g. [RATs](https://en.wikipedia.org/wiki/Remote_desktop_software#RAT), [privilege escalation](https://en.wikipedia.org/wiki/Privilege_escalation), ...) 2. enables third parties to gain control or access (e.g. [RATs](https://en.wikipedia.org/wiki/Remote_desktop_software#RAT), [privilege escalation](https://en.wikipedia.org/wiki/Privilege_escalation), ...)
3. abuses the system in an unintended way (e.g. crypto mining, proxy, ...) 3. abuses the system in an unintended way (e.g. crypto mining, proxy, ...)
## Supported Versions ## Supported Versions
We only fix security issues in the [latest stable version](https://github.com/homarr-labs/homarr/releases/latest). Meaning security issues in prior versions will not be fixed and users have to upgrade to the latest version to receive them. | Version | Supported |
| ------- | ------------------ |
| >1.0.0 | :white_check_mark: |
| <1.0.0 | :x: |
## Reporting a Vulnerability ## Reporting a Vulnerability
We use [GitHub's system for reporting vulnerabilities](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-advisories/working-with-repository-security-advisories/creating-a-repository-security-advisory). We use [GitHub's system for reporting vulnerabilities](https://docs.github.com/en/enterprise-cloud@latest/code-security/security-advisories/working-with-repository-security-advisories/creating-a-repository-security-advisory).
Click [**here to report an advisory**](https://github.com/homarr-labs/homarr/security/advisories/new). Our team will get notified and will get back to you within 1-6 business days. Click [**here to report an advisory**](https://github.com/homarr-labs/homarr/security/advisories/new). Our team will get notified and will get back to you within 1-6 business days.
+13
View File
@@ -0,0 +1,13 @@
import baseConfig from "@homarr/eslint-config/base";
import nextjsConfig from "@homarr/eslint-config/nextjs";
import reactConfig from "@homarr/eslint-config/react";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [".next/**"],
},
...baseConfig,
...reactConfig,
...nextjsConfig,
];
+29 -58
View File
@@ -1,11 +1,12 @@
// Importing env files here to validate on build // Importing env files here to validate on build
import "@homarr/auth/env"; import "@homarr/auth/env";
import "@homarr/core/infrastructure/db/env"; import "@homarr/db/env";
import "@homarr/common/env"; import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env"; import "@homarr/log/env";
import "@homarr/docker/env"; import "@homarr/docker/env";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
// Package path does not work... so we need to use relative path // Package path does not work... so we need to use relative path
@@ -16,72 +17,42 @@ const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts", requestConfig: "../../packages/translation/src/request.ts",
}); });
interface WebpackConfig {
module: {
rules: {
test: RegExp;
loader: string;
}[];
};
}
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
reactStrictMode: true, reactStrictMode: true,
// react compiler breaks mantine-react-table, so disabled for now /** We already do linting and typechecking as separate tasks in CI */
//reactCompiler: true, eslint: { ignoreDuringBuilds: true },
/** We already do typechecking as separate tasks in CI */
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
/** webpack: (config: WebpackConfig, { isServer }) => {
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612 if (isServer) {
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356 config.module.rules.push({
*/ test: /\.node$/,
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"], loader: "node-loader",
});
}
return config;
},
experimental: { experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
webpackMemoryOptimizations: true,
}, },
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: { images: {
localPatterns: [ domains: ["cdn.jsdelivr.net"],
{
pathname: "/**",
search: "",
},
],
},
// skipcq: JS-0116
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
async headers() {
return [
{
source: "/sw.js",
headers: [
{ key: "Content-Type", value: "application/javascript; charset=utf-8" },
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" },
],
},
{
source: "/(.*)", // Apply CSP to all routes
headers: [
{
key: "Content-Security-Policy",
// worker-src / media-src with blob: is necessary for video.js, see https://github.com/homarr-labs/homarr/issues/3912 and https://stackoverflow.com/questions/65792855/problem-with-video-js-and-content-security-policy-csp
value: `
default-src 'self';
script-src * 'unsafe-inline' 'unsafe-eval';
worker-src * blob:;
base-uri 'self';
connect-src *;
style-src * 'unsafe-inline';
frame-ancestors *;
frame-src *;
form-action 'self';
img-src * data:;
font-src * data:;
media-src * data: blob:;
`
.replace(/\s{2,}/g, " ")
.trim(),
},
],
},
];
}, },
}; };
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
export default withNextIntl(nextConfig); export default withNextIntl(nextConfig);
+61 -60
View File
@@ -7,33 +7,34 @@
"build": "pnpm with-env next build", "build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules", "clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev", "dev": "pnpm with-env next dev",
"format": "oxfmt .", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "oxlint", "lint": "eslint",
"start": "pnpm with-env next start", "start": "pnpm with-env next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --" "with-env": "dotenv -e ../../.env --"
}, },
"prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@dnd-kit/core": "catalog:", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "catalog:", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "catalog:", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "catalog:", "@dnd-kit/utilities": "^3.2.2",
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0", "@homarr/boards": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0", "@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0", "@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0", "@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "catalog:", "@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0", "@homarr/icons": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
@@ -43,63 +44,63 @@
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0", "@homarr/spotlight": "workspace:^0.1.0",
"@homarr/tasks": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/websocket": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "catalog:", "@mantine/colors-generator": "^8.0.1",
"@mantine/core": "catalog:", "@mantine/core": "^8.0.1",
"@mantine/dropzone": "catalog:", "@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "catalog:", "@mantine/hooks": "^8.0.1",
"@mantine/modals": "catalog:", "@mantine/modals": "^8.0.1",
"@mantine/tiptap": "catalog:", "@mantine/tiptap": "^8.0.1",
"@tabler/icons-react": "catalog:", "@million/lint": "1.0.14",
"@tanstack/react-query": "catalog:", "@tabler/icons-react": "^3.33.0",
"@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query": "^5.76.2",
"@tanstack/react-query-next-experimental": "catalog:", "@tanstack/react-query-devtools": "^5.76.2",
"@trpc/client": "catalog:", "@tanstack/react-query-next-experimental": "^5.76.2",
"@trpc/next": "catalog:", "@trpc/client": "^11.1.2",
"@trpc/react-query": "catalog:", "@trpc/next": "^11.1.2",
"@trpc/server": "catalog:", "@trpc/react-query": "^11.1.2",
"@xterm/addon-canvas": "catalog:", "@trpc/server": "^11.1.2",
"@xterm/addon-fit": "catalog:", "@xterm/addon-canvas": "^0.7.0",
"@xterm/xterm": "catalog:", "@xterm/addon-fit": "0.10.0",
"babel-plugin-react-compiler": "catalog:", "@xterm/xterm": "^5.5.0",
"chroma-js": "catalog:", "chroma-js": "^3.1.2",
"clsx": "catalog:", "clsx": "^2.1.1",
"dayjs": "catalog:", "dayjs": "^1.11.13",
"dotenv": "catalog:", "dotenv": "^16.5.0",
"flag-icons": "catalog:", "flag-icons": "^7.3.2",
"glob": "catalog:", "glob": "^11.0.2",
"isomorphic-dompurify": "catalog:", "jotai": "^2.12.4",
"jotai": "catalog:", "mantine-react-table": "2.0.0-beta.9",
"mantine-react-table": "catalog:", "next": "15.3.2",
"next": "catalog:", "postcss-preset-mantine": "^1.17.0",
"postcss-preset-mantine": "catalog:", "prismjs": "^1.30.0",
"prismjs": "catalog:", "react": "19.1.0",
"react": "catalog:", "react-dom": "19.1.0",
"react-dom": "catalog:", "react-error-boundary": "^6.0.0",
"react-error-boundary": "catalog:", "react-simple-code-editor": "^0.14.1",
"react-simple-code-editor": "catalog:", "sass": "^1.89.0",
"sass": "catalog:", "superjson": "2.2.2",
"superjson": "catalog:", "swagger-ui-react": "^5.22.0",
"swagger-ui-react": "catalog:", "use-deep-compare-effect": "^1.8.1",
"use-deep-compare-effect": "catalog:", "zod": "^3.25.23"
"yaml": "catalog:",
"zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "catalog:", "@types/chroma-js": "3.1.1",
"@types/node": "catalog:", "@types/node": "^22.15.21",
"@types/prismjs": "catalog:", "@types/prismjs": "^1.26.5",
"@types/react": "catalog:", "@types/react": "19.1.5",
"@types/react-dom": "catalog:", "@types/react-dom": "19.1.5",
"@types/swagger-ui-react": "catalog:", "@types/swagger-ui-react": "^5.18.0",
"concurrently": "catalog:", "concurrently": "^9.1.2",
"node-loader": "catalog:", "eslint": "^9.27.0",
"typescript": "catalog:" "node-loader": "^2.1.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 KiB

-7
View File
@@ -1,7 +0,0 @@
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
@@ -2,23 +2,30 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider } from "@mantine/core"; import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common"; import { parseCookies, setClientCookie } from "@homarr/common";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions"; import { colorSchemeCookieKey } from "@homarr/definitions";
import { theme } from "@homarr/ui";
export const CustomMantineProvider = ({ export const CustomMantineProvider = ({
children, children,
defaultColorScheme, defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => { }: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const manager = useColorSchemeManager(); const manager = useColorSchemeManager();
return ( return (
<DirectionProvider> <DirectionProvider>
<MantineProvider defaultColorScheme={defaultColorScheme} colorSchemeManager={manager} theme={theme}> <MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={createTheme({
primaryColor: "red",
autoContrast: true,
})}
>
{children} {children}
</MantineProvider> </MantineProvider>
</DirectionProvider> </DirectionProvider>
@@ -28,7 +35,7 @@ export const CustomMantineProvider = ({
export function useColorSchemeManager(): MantineColorSchemeManager { export function useColorSchemeManager(): MantineColorSchemeManager {
const { data: session } = useSession(); const { data: session } = useSession();
const updateCookieValue = (value: MantineColorScheme) => { const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" }); setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
}; };
@@ -53,6 +60,7 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
}, },
set: (value) => { set: (value) => {
if (value === "auto") return;
try { try {
if (session) { if (session) {
mutateColorScheme({ colorScheme: value }); mutateColorScheme({ colorScheme: value });
@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4"; import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
@@ -64,23 +64,16 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
<Stack gap="xl"> <Stack gap="xl">
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg"> <Stack gap="lg">
<TextInput <TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
label={t("field.username.label")}
id="username"
autoComplete="username"
{...form.getInputProps("username")}
/>
<CustomPasswordInput <CustomPasswordInput
withPasswordRequirements withPasswordRequirements
label={t("field.password.label")} label={t("field.password.label")}
id="password"
autoComplete="new-password" autoComplete="new-password"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<PasswordInput <PasswordInput
label={t("field.passwordConfirm.label")} label={t("field.passwordConfirm.label")}
id="password-confirm"
autoComplete="new-password" autoComplete="new-password"
{...form.getInputProps("confirmPassword")} {...form.getInputProps("confirmPassword")}
/> />
@@ -59,7 +59,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
<Card w={64 * 6} maw="90vw"> <Card withBorder w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} /> <RegistrationForm invite={invite} />
</Card> </Card>
<Text size="xs" c="gray.5" ta="center"> <Text size="xs" c="gray.5" ta="center">
@@ -5,15 +5,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core"; import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { z } from "zod/v4"; import { z } from "zod";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { UseFormReturnType } from "@homarr/form"; import type { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { userSignInSchema } from "@homarr/validation/user"; import { userSignInSchema } from "@homarr/validation/user";
type Provider = "credentials" | "ldap" | "oidc"; type Provider = "credentials" | "ldap" | "oidc";
@@ -71,7 +70,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
// Redirect to the callback URL if the response is defined and comes from a credentials provider (ldap or credentials). oidc is redirected automatically. // Redirect to the callback URL if the response is defined and comes from a credentials provider (ldap or credentials). oidc is redirected automatically.
await revalidatePathActionAsync("/"); await revalidatePathActionAsync("/");
router.push(sanitizeRedirectionUrl(callbackUrl)); router.push(callbackUrl);
}, },
[t, router, callbackUrl], [t, router, callbackUrl],
); );
@@ -117,18 +116,8 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
<> <>
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}> <form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}>
<Stack gap="lg"> <Stack gap="lg">
<TextInput <TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
label={t("field.username.label")} <PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
id="username"
autoComplete="username"
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("field.password.label")}
id="password"
autoComplete="current-password"
{...form.getInputProps("password")}
/>
{providers.includes("credentials") && ( {providers.includes("credentials") && (
<Stack gap="sm"> <Stack gap="sm">
@@ -162,7 +151,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
interface SubmitButtonProps { interface SubmitButtonProps {
isPending: boolean; isPending: boolean;
form: UseFormReturnType<FormType>; form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
provider: "credentials" | "ldap"; provider: "credentials" | "ldap";
} }
@@ -198,7 +187,7 @@ const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps)
{tForgotPassword("label")} {tForgotPassword("label")}
</Anchor> </Anchor>
<Collapse expanded={visible}> <Collapse in={visible}>
<Card> <Card>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm">{tForgotPassword("description")}</Text> <Text size="sm">{tForgotPassword("description")}</Text>
@@ -4,7 +4,6 @@ import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { env } from "@homarr/auth/env"; import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form"; import { LoginForm } from "./_login-form";
@@ -20,7 +19,7 @@ export default async function Login(props: LoginProps) {
const session = await auth(); const session = await auth();
if (session) { if (session) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl)); redirect(searchParams.callbackUrl ?? "/");
} }
const t = await getScopedI18n("user.page.login"); const t = await getScopedI18n("user.page.login");
@@ -37,7 +36,7 @@ export default async function Login(props: LoginProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
<Card w={64 * 6} maw="90vw"> <Card withBorder w={64 * 6} maw="90vw">
<LoginForm <LoginForm
providers={env.AUTH_PROVIDERS} providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME} oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
@@ -1,27 +1,70 @@
"use client"; "use client";
import { Box, Stack } from "@mantine/core"; 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 { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background"; import { BoardBackgroundVideo } from "~/components/layout/background";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { useIsBoardReady } from "./_ready-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 = () => { export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
const isReady = useIsBoardReady();
const fullWidthSortedSections = board.sections const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category") .filter((section) => section.kind === "empty" || section.kind === "category")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
const ref = useRef<HTMLDivElement>(null);
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative">
<BoardBackgroundVideo /> <BoardBackgroundVideo />
<Stack h="100%"> <LoadingOverlay
visible={!isReady}
transitionProps={{ duration: 500 }}
loaderProps={{ size: "lg" }}
h={fullHeightWithoutHeaderAndFooter}
/>
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
{fullWidthSortedSections.map((section) => {fullWidthSortedSections.map((section) =>
section.kind === "empty" ? ( section.kind === "empty" ? (
// Unique keys per layout to always reinitialize the gridstack
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} /> <BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : ( ) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} /> <BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
@@ -11,9 +11,8 @@ import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { isNullOrWhitespace } from "@homarr/common"; import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -23,8 +22,6 @@ import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client"; import { DynamicClientBoard } from "./_dynamic-client";
import { BoardContentHeaderActions } from "./_header-actions"; import { BoardContentHeaderActions } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
export type Params = Record<string, unknown>; export type Params = Record<string, unknown>;
interface Props<TParams extends Params> { interface Props<TParams extends Params> {
@@ -41,11 +38,13 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}), }),
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => { page: async ({ params }: { params: Promise<TParams> }) => {
const resolvedParams = await params; const session = await auth();
const integrations = await getIntegrationsWithPermissionsAsync(session);
const board = await getInitialBoard(await params);
const queryClient = getQueryClient(); const queryClient = getQueryClient();
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]); // Prefetch item data
const itemsMap = board.items.reduce((acc, item) => { const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind); const existing = acc.get(item.kind);
if (existing) { if (existing) {
@@ -56,20 +55,11 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
return acc; return acc;
}, new Map<WidgetKind, Item[]>()); }, new Map<WidgetKind, Item[]>());
const [integrations] = await Promise.all([ for (const [kind, items] of itemsMap) {
getIntegrationsWithPermissionsAsync(session), await prefetchForKindAsync(kind, queryClient, items).catch((error) => {
...Array.from(itemsMap).map(([kind, items]) => logger.error(new Error("Failed to prefetch widget", { cause: error }));
prefetchForKindAsync(kind, queryClient, items).catch((error) => { });
logger.error( }
new ErrorWithMetadata(
"Failed to prefetch widget",
{ widgetKind: kind, itemCount: items.length },
{ cause: error },
),
);
}),
),
]);
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <HydrationBoundary state={dehydrate(queryClient)}>
@@ -1,6 +1,8 @@
"use client"; "use client";
import type { MouseEvent } from "react";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Group, Menu, ScrollArea } from "@mantine/core"; import { Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
@@ -18,17 +20,14 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection"; import { AppSelectModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useItemActions } from "~/components/board/items/item-actions"; import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { ItemSelectModal } from "~/components/board/items/item-select-modal";
@@ -44,7 +43,7 @@ export const BoardContentHeaderActions = () => {
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) { if (!hasChangeAccess) {
return <SelectBoardsMenu />; return null; // Hide actions for user without access
} }
return ( return (
@@ -63,7 +62,6 @@ export const BoardContentHeaderActions = () => {
}; };
const AddMenu = () => { const AddMenu = () => {
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
@@ -98,18 +96,17 @@ const AddMenu = () => {
const handleSelectApp = useCallback(() => { const handleSelectApp = useCallback(() => {
openAppSelectModal({ openAppSelectModal({
onSelect: (app) => { onSelect: (appId) => {
createItem({ createItem({
kind: "app", kind: "app",
options: { appId: app.id }, options: { appId },
}); });
}, },
withCreate: session?.user.permissions.includes("app-create") ?? false,
}); });
}, [openAppSelectModal, createItem]); }, [openAppSelectModal, createItem]);
return ( return (
<Menu position="bottom-end"> <Menu position="bottom-end" withArrow>
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4}> <HeaderButton w="auto" px={4}>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
@@ -169,7 +166,7 @@ const EditModeMenu = () => {
open(); open();
}, [board, isEditMode, saveBoard, open]); }, [board, isEditMode, saveBoard, open]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); useHotkeys([["mod+e", toggle]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
return ( return (
@@ -183,7 +180,7 @@ const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return ( return (
<Menu position="bottom-end"> <Menu position="bottom-end" withArrow>
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4}> <HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} /> <IconReplace stroke={1.5} />
@@ -207,22 +204,17 @@ const SelectBoardsMenu = () => {
); );
}; };
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => { const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n(); const t = useI18n();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (!isDirty) return; const handleClick = (event: MouseEvent<HTMLElement>) => {
const handleClick = (event: Event) => {
const target = (event.target as HTMLElement).closest("a"); const target = (event.target as HTMLElement).closest("a");
if (!target) { if (!target) return;
console.warn("No anchor element found for click event", event); if (!isDirty) return;
return;
}
event.preventDefault(); event.preventDefault();
@@ -239,30 +231,35 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
}; };
const handlePopState = (event: Event) => { const handlePopState = (event: Event) => {
window.history.pushState(null, document.title, window.location.href); if (isDirty) {
event.preventDefault(); window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
} else {
window.history.back();
}
}; };
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return;
if (env.NODE_ENV === "development") return; // Allow to reload in development if (env.NODE_ENV === "development") return; // Allow to reload in development
event.preventDefault(); event.preventDefault();
event.returnValue = true; event.returnValue = true;
}; };
const anchors = document.querySelectorAll(anchorSelector); document.querySelectorAll("a").forEach((link) => {
anchors.forEach((link) => { link.addEventListener("click", handleClick as never);
link.addEventListener("click", handleClick);
}); });
window.addEventListener("popstate", handlePopState); window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
return () => { return () => {
anchors.forEach((link) => { document.querySelectorAll("a").forEach((link) => {
link.removeEventListener("click", handleClick); link.removeEventListener("click", handleClick as never);
window.removeEventListener("popstate", handlePopState);
}); });
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]); }, [isDirty]);
}; };
@@ -1,19 +1,43 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext } from "react"; import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
const BoardReadyContext = createContext<{ const BoardReadyContext = createContext<{
isReady: boolean;
markAsReady: (id: string) => void; markAsReady: (id: string) => void;
} | null>(null); } | null>(null);
export const BoardReadyProvider = ({ children }: PropsWithChildren) => { export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function const pathname = usePathname();
const markAsReady = useCallback((_id: string) => {}, []); const utils = clientApi.useUtils();
const board = useRequiredBoard();
const [readySections, setReadySections] = useState<string[]>([]);
// Reset sections required for ready state
useEffect(() => {
return () => {
setReadySections([]);
};
}, [pathname, utils]);
useEffect(() => {
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [board.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
}, []);
return ( return (
<BoardReadyContext.Provider <BoardReadyContext.Provider
value={{ value={{
isReady: board.sections.length === readySections.length,
markAsReady, markAsReady,
}} }}
> >
@@ -31,3 +55,13 @@ export const useMarkSectionAsReady = () => {
return context.markAsReady; return context.markAsReady;
}; };
export const useIsBoardReady = () => {
const context = useContext(BoardReadyContext);
if (!context) {
throw new Error("BoardReadyProvider is required");
}
return context.isReady;
};
@@ -24,7 +24,6 @@ export const BoardMantineProvider = ({
}, },
primaryColor: "primaryColor", primaryColor: "primaryColor",
autoContrast: true, autoContrast: true,
defaultRadius: "sm",
fontSizes: { fontSizes: {
"2xl": rem(24), "2xl": rem(24),
"3xl": rem(28), "3xl": rem(28),
@@ -17,7 +17,6 @@ import {
useMantineTheme, useMantineTheme,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconX } from "@tabler/icons-react";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -83,7 +82,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor> <Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Collapse expanded={showPreview}> <Collapse in={showPreview}>
<Stack> <Stack>
<ColorsPreview previewColor={form.values.primaryColor} /> <ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} /> <ColorsPreview previewColor={form.values.secondaryColor} />
@@ -103,25 +102,12 @@ export const ColorSettingsContent = ({ board }: Props) => {
</InputWrapper> </InputWrapper>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end"> <ColorInput
<ColorInput label={t("board.field.iconColor.label")}
label={t("board.field.iconColor.label")} format="hex"
format="hex" swatches={Object.values(theme.colors).map((color) => color[6])}
swatches={Object.values(theme.colors).map((color) => color[6])} {...form.getInputProps("iconColor")}
flex={1} />
{...form.getInputProps("iconColor")}
/>
<Button
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select <Select
label={t("board.field.itemRadius.label")} label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")} description={t("board.field.itemRadius.description")}
@@ -120,14 +120,11 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
/> />
{session?.user.permissions.includes("media-upload") && ( {session?.user.permissions.includes("media-upload") && (
<UploadMedia <UploadMedia
onSuccess={(medias) => { onSuccess={({ url }) =>
const first = medias.at(0);
if (!first) return;
startTransition(() => { startTransition(() => {
form.setFieldValue("backgroundImageUrl", first.url); form.setFieldValue("backgroundImageUrl", url);
}); })
}} }
> >
{({ onClick, loading }) => ( {({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default"> <ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
@@ -2,7 +2,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core"; import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core";
import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks"; import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
import { useUpdateBoard } from "@homarr/boards/updater"; import { useUpdateBoard } from "@homarr/boards/updater";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
@@ -10,7 +10,6 @@ import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
@@ -54,6 +53,7 @@ export const GeneralSettingsContent = ({ board }: Props) => {
); );
useLogoPreview(form.values.logoImageUrl); useLogoPreview(form.values.logoImageUrl);
useFaviconPreview(form.values.faviconImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle); const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
// Cleanup for not applied changes of the page title and logo image URL // Cleanup for not applied changes of the page title and logo image URL
@@ -75,7 +75,6 @@ export const GeneralSettingsContent = ({ board }: Props) => {
pageTitle: values.pageTitle, pageTitle: values.pageTitle,
logoImageUrl: values.logoImageUrl, logoImageUrl: values.logoImageUrl,
}; };
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
savePartialSettings({ savePartialSettings({
id: board.id, id: board.id,
...values, ...values,
@@ -147,13 +146,7 @@ const useMetaTitlePreview = (title: string | null) => {
}; };
}; };
// Previously we used the useFavicon hook from Mantine const useFaviconPreview = (url: string | null) => {
// However if the user tried to click on a link to a different page const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
// it caused an error "Cannot read properties of null (reading 'removeChild')" useFavicon(faviconDebounced);
// Probably because the head element was null (https://github.com/mantinedev/mantine/blob/b90d9b81031f45f5d15e75f25138ed6477f65bce/packages/%40mantine/hooks/src/use-favicon/use-favicon.ts#L21)
// See https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
const link: HTMLLinkElement | null = document.querySelector('link[rel="icon"]');
if (!link) return;
link.href = url;
}; };
@@ -3,7 +3,7 @@
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core"; import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common"; import { createId } from "@homarr/db/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema } from "@homarr/validation/board"; import { boardSaveLayoutsSchema } from "@homarr/validation/board";
@@ -61,10 +61,6 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
notFound(); notFound();
} }
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
notFound();
}
throw error; throw error;
} }
}; };
@@ -6,7 +6,7 @@ import { TRPCError } from "@trpc/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context"; import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode"; import { EditModeProvider } from "@homarr/boards/edit-mode";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { logger } from "@homarr/log";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
@@ -18,8 +18,6 @@ import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context"; import { BoardReadyProvider } from "./(content)/_ready-context";
import { BoardMantineProvider } from "./(content)/_theme"; import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
@@ -47,10 +45,6 @@ export const createBoardLayout = <TParams extends Params>({
notFound(); notFound();
} }
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
notFound();
}
throw error; throw error;
}); });
const colorScheme = await getCurrentColorSchemeAsync(); const colorScheme = await getCurrentColorSchemeAsync();
@@ -1,3 +1,4 @@
import Link from "next/link";
import type { MantineColor } from "@mantine/core"; import type { MantineColor } from "@mantine/core";
import { Button, Card, Stack, Text } from "@mantine/core"; import { Button, Card, Stack, Text } from "@mantine/core";
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
@@ -7,7 +8,6 @@ import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
export const InitFinish = async () => { export const InitFinish = async () => {
@@ -15,7 +15,7 @@ export const InitFinish = async () => {
const tFinish = await getScopedI18n("init.step.finish"); const tFinish = await getScopedI18n("init.step.finish");
return ( return (
<Card w={64 * 6} maw="90vw"> <Card w={64 * 6} maw="90vw" withBorder>
<Stack> <Stack>
<Text>{tFinish("description")}</Text> <Text>{tFinish("description")}</Text>
@@ -2,7 +2,7 @@
import { Button, Card, Stack, TextInput } from "@mantine/core"; import { Button, Card, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4"; import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -12,11 +12,7 @@ import { groupCreateSchema } from "@homarr/validation/group";
export const InitGroup = () => { export const InitGroup = () => {
const t = useI18n(); const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({ const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation();
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const form = useZodForm(groupCreateSchema, { const form = useZodForm(groupCreateSchema, {
initialValues: { initialValues: {
name: "", name: "",
@@ -25,6 +21,9 @@ export const InitGroup = () => {
const handleSubmitAsync = async (values: z.infer<typeof groupCreateSchema>) => { const handleSubmitAsync = async (values: z.infer<typeof groupCreateSchema>) => {
await mutateAsync(values, { await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
onError(error) { onError(error) {
if (error.data?.code === "CONFLICT") { if (error.data?.code === "CONFLICT") {
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") }); form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
@@ -34,7 +33,7 @@ export const InitGroup = () => {
}; };
return ( return (
<Card w={64 * 6} maw="90vw"> <Card w={64 * 6} maw="90vw" withBorder>
<form onSubmit={form.onSubmit(handleSubmitAsync)}> <form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack> <Stack>
<TextInput <TextInput
@@ -13,7 +13,7 @@ interface FileInfoCardProps {
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo"); const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return ( return (
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}> <Card w={64 * 12 + 8} maw="90vw">
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Group> <Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}> <Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
@@ -20,7 +20,7 @@ export const InitImport = () => {
if (!file) { if (!file) {
return ( return (
<Card w={64 * 12 + 8} maw="90vw"> <Card w={64 * 12 + 8} maw="90vw" withBorder>
<ImportDropZone <ImportDropZone
loading={isPending} loading={isPending}
updateFile={(file) => { updateFile={(file) => {
@@ -1,8 +1,9 @@
"use client"; "use client";
import { startTransition } from "react";
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core"; import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4"; import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -16,30 +17,58 @@ import { settingsInitSchema } from "@homarr/validation/settings";
export const InitSettings = () => { export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section"); const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n(); const t = useI18n();
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation({ const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation();
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const form = useZodForm(settingsInitSchema, { initialValues: defaultServerSettings }); const form = useZodForm(settingsInitSchema, { initialValues: defaultServerSettings });
form.watch("analytics.enableGeneral", ({ value }) => {
if (!value) {
startTransition(() => {
form.setFieldValue("analytics.enableWidgetData", false);
form.setFieldValue("analytics.enableIntegrationData", false);
form.setFieldValue("analytics.enableUserData", false);
});
}
});
const handleSubmitAsync = async (values: z.infer<typeof settingsInitSchema>) => { const handleSubmitAsync = async (values: z.infer<typeof settingsInitSchema>) => {
await mutateAsync(values); await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
}; };
return ( return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}> <form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack> <Stack>
<Card w={64 * 12 + 8} maw="90vw"> <Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text> <Text fw={500}>{tSection("analytics.title")}</Text>
<Stack gap="xs"> <Stack gap="xs">
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} /> <AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
<Stack gap="xs" ps="md" w="100%">
<AnalyticsRow
kind="integrationData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableWidgetData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="widgetData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableIntegrationData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="usersData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableUserData", { type: "checkbox" })}
/>
</Stack>
</Stack> </Stack>
</Stack> </Stack>
</Card> </Card>
<Card w={64 * 12 + 8} maw="90vw"> <Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text> <Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
@@ -10,7 +10,7 @@ export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start"); const tStart = await getScopedI18n("init.step.start");
return ( return (
<Card w={64 * 6} maw="90vw"> <Card w={64 * 6} maw="90vw" withBorder>
<Stack> <Stack>
<Text>{tStart("description")}</Text> <Text>{tStart("description")}</Text>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4"; import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
@@ -26,19 +26,19 @@ export const InitUserForm = () => {
const handleSubmitAsync = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, { await mutateAsync(values, {
onSuccess() { async onSuccess() {
showSuccessNotification({ showSuccessNotification({
title: tUser("notification.success.title"), title: tUser("notification.success.title"),
message: tUser("notification.success.message"), message: tUser("notification.success.message"),
}); });
void signIn("credentials", { await signIn("credentials", {
name: values.username, name: values.username,
password: values.password, password: values.password,
redirect: false, redirect: false,
}).then(async () => {
await revalidatePathActionAsync("/init");
}); });
await revalidatePathActionAsync("/init");
}, },
onError: (error) => { onError: (error) => {
showErrorNotification({ showErrorNotification({
@@ -4,7 +4,7 @@ import { InitUserForm } from "./init-user-form";
export const InitUser = () => { export const InitUser = () => {
return ( return (
<Card w={64 * 6} maw="90vw"> <Card w={64 * 6} maw="90vw" withBorder>
<InitUserForm /> <InitUserForm />
</Card> </Card>
); );
+6 -14
View File
@@ -4,7 +4,6 @@ import { Inter } from "next/font/google";
import "@homarr/notifications/styles.css"; import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css"; import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css"; import "@homarr/ui/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss"; import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -24,9 +23,7 @@ import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics"; import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader"; import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { JotaiProvider } from "./_client-providers/jotai"; import { JotaiProvider } from "./_client-providers/jotai";
@@ -44,13 +41,13 @@ const fontSans = Inter({
export const generateMetadata = async (): Promise<Metadata> => ({ export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr", title: "Homarr",
description: description:
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.", "Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
openGraph: { openGraph: {
title: "Homarr Dashboard", title: "Homarr Dashboard",
description: description:
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.", "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", url: "https://homarr.dev",
siteName: "Homarr", siteName: "Homarr Documentation",
}, },
icons: { icons: {
icon: "/logo/logo.png", icon: "/logo/logo.png",
@@ -108,7 +105,6 @@ export default async function Layout(props: {
forceDisableStatus: serverSettings.board.forceDisableStatus, forceDisableStatus: serverSettings.board.forceDisableStatus,
}, },
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId }, search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar },
}} }}
{...innerProps} {...innerProps}
/> />
@@ -122,28 +118,24 @@ export default async function Layout(props: {
(innerProps) => <SpotlightProvider {...innerProps} />, (innerProps) => <SpotlightProvider {...innerProps} />,
]); ]);
const { locale } = await props.params;
return ( return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html <html
lang={locale} lang={(await props.params).locale}
dir={direction} dir={direction}
data-mantine-color-scheme={colorScheme} data-mantine-color-scheme={colorScheme}
style={{ style={{
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff", backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
}} }}
suppressHydrationWarning suppressHydrationWarning
> >
<head> <head>
<Analytics /> <Analytics />
<SearchEngineOptimization /> <SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head> </head>
<body className={["font-sans", fontSans.variable].join(" ")}> <body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider> <StackedProvider>
<Notifications pauseResetOnHover="notification" /> <Notifications />
<ServiceWorkerRegistration />
{props.children} {props.children}
</StackedProvider> </StackedProvider>
</body> </body>
@@ -1,32 +1,51 @@
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core"; import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
import { splitToNChunks } from "@homarr/common"; import { splitToNChunks } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import classes from "./hero-banner.module.css"; import classes from "./hero-banner.module.css";
const icons = Object.values(integrationDefs) const icons = [
.filter((int) => int.name !== "Mock") "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
.map((int) => int.iconUrl); "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/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/homarr-labs/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
];
const countIconGroups = 3; const countIconGroups = 3;
const animationDurationInSeconds = icons.length; const animationDurationInSeconds = 12;
const arrayInChunks = splitToNChunks(icons, countIconGroups); const arrayInChunks = splitToNChunks(icons, countIconGroups);
const gridSpan = 12 / countIconGroups;
export const HeroBanner = async () => { export const HeroBanner = () => {
const t = await getScopedI18n("management.page.home"); const gridSpan = 12 / countIconGroups;
return ( return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative"> <Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
<Stack gap={0}> <Stack gap={0}>
<Title fz={{ base: "h4", md: "h2" }} c="dimmed"> <Title fz={{ base: "h4", md: "h2" }} c="dimmed">
{t("heroBanner.title")} Welcome back to your
</Title> </Title>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} /> <Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title> <Title fz={{ base: "h3", md: "h1" }}>Homarr Board</Title>
</Group> </Group>
</Stack> </Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute"> <Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
@@ -42,24 +61,12 @@ export const HeroBanner = async () => {
}} }}
> >
{arrayInChunks[columnIndex]?.map((icon, index) => ( {arrayInChunks[columnIndex]?.map((icon, index) => (
<Image <Image key={`grid-column-${columnIndex}-scroll-1-${index}`} src={icon} radius="md" w={50} h={50} />
key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon}
fit={"contain"}
w={50}
h={50}
/>
))} ))}
{/* This is used for making the animation seem seamless */} {/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => ( {arrayInChunks[columnIndex]?.map((icon, index) => (
<Image <Image key={`grid-column-${columnIndex}-scroll-2-${index}`} src={icon} radius="md" w={50} h={50} />
key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon}
fit={"contain"}
w={50}
h={50}
/>
))} ))}
</Stack> </Stack>
</GridCol> </GridCol>
@@ -1,7 +1,3 @@
.contributorCard { .contributorCard {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
} }
.dependencyCard:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
@@ -11,40 +11,21 @@ import {
Center, Center,
Flex, Flex,
Group, Group,
Kbd, List,
SimpleGrid, ListItem,
Stack, Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text, Text,
ThemeIcon,
Title, Title,
UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
IconKeyboard,
IconLanguage,
IconLibrary,
IconMoneybagHeart,
IconPackage,
IconUsers,
} from "@tabler/icons-react";
import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import type { PackageJsonDependencies } from "~/versions/package-reader"; import { getPackageAttributesAsync } from "~/versions/package-reader";
import { getPackageVersion } from "~/versions/package-reader";
import type githubContributorsJson from "../../../../../../../static-data/contributors.json"; import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json"; import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
import classes from "./about.module.css"; import classes from "./about.module.css";
@@ -67,10 +48,7 @@ const getHostAsync = async () => {
export default async function AboutPage() { export default async function AboutPage() {
const baseServerUrl = `http://${await getHostAsync()}`; const baseServerUrl = `http://${await getHostAsync()}`;
const t = await getScopedI18n("management.page.about"); const t = await getScopedI18n("management.page.about");
const version = getPackageVersion(); const attributes = await getPackageAttributesAsync();
const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) =>
res.json(),
)) as PackageJsonDependencies;
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) => const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
res.json(), res.json(),
)) as typeof githubContributorsJson; )) as typeof githubContributorsJson;
@@ -79,10 +57,6 @@ export default async function AboutPage() {
res.json(), res.json(),
)) as typeof crowdinContributorsJson; )) as typeof crowdinContributorsJson;
const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
(res) => res.json(),
)) as typeof openCollectiveContributorsJson;
return ( return (
<div> <div>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
@@ -90,8 +64,10 @@ export default async function AboutPage() {
<Group py="lg"> <Group py="lg">
<Image src={homarrLogoPath} width={100} height={100} alt="" /> <Image src={homarrLogoPath} width={100} height={100} alt="" />
<Stack gap={0}> <Stack gap={0}>
<Title order={1}>Homarr</Title> <Title order={1} tt="uppercase">
<Title order={2}>{t("version", { version })}</Title> Homarr
</Title>
<Title order={2}>{t("version", { version: attributes.version })}</Title>
</Stack> </Stack>
</Group> </Group>
</Center> </Center>
@@ -146,111 +122,31 @@ export default async function AboutPage() {
</Flex> </Flex>
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem value="sponsors">
<AccordionControl icon={<IconMoneybagHeart size="1rem" />}>
<Stack gap={0}>
<Text>{t("accordion.sponsors.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.sponsors.subtitle", {
count: String(openCollectiveContributors.length),
})}
</Text>
</Stack>
</AccordionControl>
<AccordionPanel>
<Flex wrap="wrap" gap="xs">
{openCollectiveContributors.map((sponsor) => (
<GenericContributorLinkCard
key={sponsor.slug}
link={`https://opencollective.com/${sponsor.slug}`}
image={sponsor.imageUrl}
name={sponsor.name}
/>
))}
</Flex>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="libraries"> <AccordionItem value="libraries">
<AccordionControl icon={<IconLibrary size="1rem" />}> <AccordionControl icon={<IconLibrary size="1rem" />}>
<Stack gap={0}> <Stack gap={0}>
<Text>{t("accordion.libraries.title")}</Text> <Text>{t("accordion.libraries.title")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("accordion.libraries.subtitle", { {t("accordion.libraries.subtitle", {
count: String(Object.keys(dependencies).length), count: String(Object.keys(attributes.dependencies).length),
})} })}
</Text> </Text>
</Stack> </Stack>
</AccordionControl> </AccordionControl>
<AccordionPanel> <AccordionPanel>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md"> <List>
{Object.entries(dependencies) {Object.entries(attributes.dependencies)
.filter(([, value]) => !value.includes("workspace:"))
.sort(([key1], [key2]) => key1.localeCompare(key2)) .sort(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => ( .map(([key, value]) => (
<UnstyledButton <ListItem key={key}>
key={name} {value.includes("workspace:") ? (
component="a" <Text>{key}</Text>
href={`https://www.npmjs.com/package/${name}`} ) : (
target="_blank" <a href={`https://www.npmjs.com/package/${key}`}>{key}</a>
> )}
<Card radius="md" p="xs" className={classes.dependencyCard}> </ListItem>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg">
<IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={1} title={name}>
{name}
</Text>
<Text size="sm" c="dimmed">
v{version.replace("^", "").replace("~", "")}
</Text>
</Stack>
</Group>
</Card>
</UnstyledButton>
))} ))}
</SimpleGrid> </List>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="hotkeys">
<AccordionControl icon={<IconKeyboard size="1rem" />}>
<Stack gap={0}>
<Text>{t("accordion.hotkeys.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.hotkeys.subtitle")}
</Text>
</Stack>
</AccordionControl>
<AccordionPanel>
<Table>
<TableThead>
<TableTr>
<TableTh>{t("accordion.hotkeys.field.shortcut")}</TableTh>
<TableTh>{t("accordion.hotkeys.field.action")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{objectEntries(hotkeys).map(([key, shortcut]) => (
<TableTr key={key}>
<TableTd>
<Kbd size="md">
{shortcut
.split("+")
.map((key) => capitalize(key.trim()))
.join(" + ")}
</Kbd>
</TableTd>
<TableTd>{t(`accordion.hotkeys.action.${key}`)}</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
<Text size="sm" c="dimmed">
{t("accordion.hotkeys.note")}
</Text>
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
@@ -2,7 +2,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { z } from "zod/v4"; import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -1,18 +1,19 @@
import { Fragment } from "react"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react"; import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { SearchInput, TablePagination } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button"; import { AppDeleteButton } from "./_app-delete-button";
@@ -38,31 +39,34 @@ export default async function AppsPage(props: AppsPageProps) {
const { items: apps, totalCount } = await api.app.getPaginated(searchParams); const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
const canCreate = session.user.permissions.includes("app-create");
return ( return (
<ManagePageLayout <ManageContainer>
title={t("page.list.title")} <DynamicBreadcrumb />
primaryAction={ <Stack>
canCreate ? ( <Title>{t("page.list.title")}</Title>
<MobileAffixButton component={Link} href="/manage/apps/new"> <Group justify="space-between" align="center">
{t("page.create.title")} <SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
</MobileAffixButton> {session.user.permissions.includes("app-create") && (
) : undefined <MobileAffixButton component={Link} href="/manage/apps/new">
} {t("page.create.title")}
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />} </MobileAffixButton>
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />} )}
floatingPrimaryAction={canCreate} </Group>
> {apps.length === 0 && <AppNoResults />}
{apps.length === 0 && <AppNoResults />} {apps.length > 0 && (
{apps.length > 0 && ( <Stack gap="sm">
<Stack gap="sm"> {apps.map((app) => (
{apps.map((app) => ( <AppCard key={app.id} app={app} />
<AppCard key={app.id} app={app} /> ))}
))} </Stack>
</Stack> )}
)}
</ManagePageLayout> {/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
); );
} }
@@ -75,7 +79,7 @@ const AppCard = async ({ app }: AppCardProps) => {
const session = await auth(); const session = await auth();
return ( return (
<Card> <Card withBorder>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}> <Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
<Avatar <Avatar
@@ -94,12 +98,7 @@ const AppCard = async ({ app }: AppCardProps) => {
</Text> </Text>
{app.description && ( {app.description && (
<Text size="sm" c="gray.6" lineClamp={4}> <Text size="sm" c="gray.6" lineClamp={4}>
{app.description.split("\n").map((line, index) => ( {app.description}
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text> </Text>
)} )}
{app.href && ( {app.href && (
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core"; import { Menu } from "@mantine/core";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react"; import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
@@ -11,7 +12,6 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection"; import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -84,6 +84,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
id: board.id, id: board.id,
name: board.name, name: board.name,
}, },
onSuccess: async () => {
await revalidatePathActionAsync("/manage/boards");
},
}); });
}, [board.id, board.name, openDuplicateModal]); }, [board.id, board.name, openDuplicateModal]);
@@ -1,38 +1,43 @@
"use client"; "use client";
import { Button, Menu } from "@mantine/core"; import { Affix, Button, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react"; import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection"; import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => { export const CreateBoardButton = () => {
const t = useI18n(); const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal); const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal); const { openModal: openImportModal } = useModalAction(ImportBoardModal);
const buttonGroupContent = (
<>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
{t("management.page.board.action.new.label")}
</Button>
<Menu position="bottom-end">
<Menu.Target>
<Button px="xs" ms={1}>
<IconChevronDown size="1rem" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
{t("board.action.oldImport.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
return ( return (
<ManageMobilePrimaryAction> <>
<Button.Group> <Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}> <Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
{t("management.page.board.action.new.label")} <Button.Group>{buttonGroupContent}</Button.Group>
</Button> </Affix>
<Menu position="bottom-end"> </>
<Menu.Target>
<Button px="xs" ms={1}>
<IconChevronDown size="1rem" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
{t("board.action.oldImport.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Button.Group>
</ManageMobilePrimaryAction>
); );
}; };
@@ -1,3 +1,4 @@
import Link from "next/link";
import { import {
ActionIcon, ActionIcon,
Badge, Badge,
@@ -9,27 +10,22 @@ import {
Group, Group,
Menu, Menu,
MenuTarget, MenuTarget,
Stack,
Text, Text,
Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
IconDeviceMobile,
IconDotsVertical,
IconHomeFilled,
IconLayoutDashboard,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManageContainer } from "~/components/manage/manage-container";
import { NoResults } from "~/components/no-results"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button"; import { CreateBoardButton } from "./_components/create-board-button";
@@ -40,22 +36,23 @@ export default async function ManageBoardsPage() {
const canCreateBoards = session?.user.permissions.includes("board-create"); const canCreateBoards = session?.user.permissions.includes("board-create");
return ( return (
<ManagePageLayout <ManageContainer>
title={t("title")} <DynamicBreadcrumb />
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined} <Stack>
floatingPrimaryAction={canCreateBoards} <Group justify="space-between">
> <Title mb="md">{t("title")}</Title>
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />} {canCreateBoards && <CreateBoardButton />}
{boards.length > 0 && ( </Group>
<Grid>
<Grid mb={{ base: "xl", md: 0 }}>
{boards.map((board) => ( {boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}> <GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} /> <BoardCard board={board} />
</GridCol> </GridCol>
))} ))}
</Grid> </Grid>
)} </Stack>
</ManagePageLayout> </ManageContainer>
); );
} }
@@ -70,14 +67,16 @@ const BoardCard = async ({ board }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock; const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return ( return (
<Card> <Card radius="lg" withBorder>
<CardSection p="sm" withBorder> <CardSection p="sm" withBorder>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
<Tooltip label={t(`visibility.${visibility}`)}> <Tooltip label={t(`visibility.${visibility}`)}>
<VisibilityIcon size={20} stroke={1.5} /> <VisibilityIcon size={20} stroke={1.5} />
</Tooltip> </Tooltip>
<Text fw={700}>{board.name}</Text> <Text fw="bolder" tt="uppercase">
{board.name}
</Text>
</Group> </Group>
<Group> <Group>
@@ -125,7 +124,6 @@ const BoardCard = async ({ board }: BoardCardProps) => {
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }} style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default" variant="default"
size="lg" size="lg"
aria-label={t("action.settings.label")}
> >
<IconDotsVertical size={16} stroke={1.5} /> <IconDotsVertical size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -33,7 +33,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const KindIcon = integrationSecretIcons[secret.kind]; const KindIcon = integrationSecretIcons[secret.kind];
return ( return (
<Card> <Card withBorder>
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Group> <Group>
@@ -58,12 +58,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
</Tooltip> </Tooltip>
)} )}
{isPublic ? ( {isPublic ? (
<ActionIcon <ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
>
<DisplayIcon size={16} stroke={1.5} /> <DisplayIcon size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
) : null} ) : null}
@@ -84,7 +79,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
</Button> </Button>
</Group> </Group>
</Group> </Group>
<Collapse expanded={editMode}>{children}</Collapse> <Collapse in={editMode}>{children}</Collapse>
</Stack> </Stack>
</Card> </Card>
); );
@@ -1,15 +1,4 @@
import { import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
IconCode,
IconGrid3x3,
IconKey,
IconLink,
IconMessage,
IconPassword,
IconPasswordUser,
IconPlug,
IconServer,
IconUser,
} from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
@@ -20,12 +9,4 @@ export const integrationSecretIcons = {
password: IconPassword, password: IconPassword,
realm: IconServer, realm: IconServer,
tokenId: IconGrid3x3, tokenId: IconGrid3x3,
personalAccessToken: IconPasswordUser,
topic: IconMessage,
url: IconLink,
opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
} satisfies Record<IntegrationSecretKind, TablerIcon>; } satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { ChangeEventHandler, FocusEventHandler } from "react"; import type { ChangeEventHandler, FocusEventHandler } from "react";
import { PasswordInput, Textarea, TextInput } from "@mantine/core"; import { PasswordInput, TextInput } from "@mantine/core";
import { integrationSecretKindObject } from "@homarr/definitions"; import { integrationSecretKindObject } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
@@ -14,9 +14,9 @@ interface IntegrationSecretInputProps {
label?: string; label?: string;
kind: IntegrationSecretKind; kind: IntegrationSecretKind;
value?: string; value?: string;
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>; onChange: ChangeEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>; onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
error?: string; error?: string;
} }
@@ -31,19 +31,6 @@ export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => { const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n(); const t = useI18n();
const Icon = integrationSecretIcons[kind]; const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
if (multiline) {
return (
<Textarea
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
minRows={2}
/>
);
}
return ( return (
<TextInput <TextInput
@@ -58,21 +45,6 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => { const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n(); const t = useI18n();
const Icon = integrationSecretIcons[kind]; const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
if (multiline) {
return (
<Textarea
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t("integration.secrets.secureNotice")}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
minRows={2}
/>
);
}
return ( return (
<PasswordInput <PasswordInput
@@ -1,62 +0,0 @@
import { useCallback } from "react";
import { SegmentedControl } from "@mantine/core";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
interface FormType {
secrets: { kind: IntegrationSecretKind; value: string | null }[];
}
interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
defaultKinds?: IntegrationSecretKind[];
secretKinds: IntegrationSecretKind[][];
form: UseFormReturnType<TFormType, TFormType>;
}
export const SecretKindsSegmentedControl = <TFormType extends FormType>({
defaultKinds,
secretKinds,
form,
}: SecretKindsSegmentedControlProps<TFormType>) => {
const t = useScopedI18n("integration.secrets");
const defaultValue = defaultKinds?.length === 0 ? "empty" : defaultKinds?.join("-");
const secretKindGroups = secretKinds.map((kinds) => ({
label:
kinds.length === 0
? t("noSecretsRequired.segmentTitle")
: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
value: kinds.length === 0 ? "empty" : kinds.join("-"),
}));
const onChange = useCallback(
(value: string) => {
if (value === "empty") {
const emptyValues = [] satisfies FormType["secrets"];
// @ts-expect-error somehow it is not able to understand that secrets is an array?
form.setFieldValue("secrets", emptyValues);
return;
}
const kinds = value.split("-") as IntegrationSecretKind[];
const secrets = kinds.map((kind) => ({
kind,
value: "",
})) satisfies FormType["secrets"];
// @ts-expect-error somehow it is not able to understand that secrets is an array?
form.setFieldValue("secrets", secrets);
},
[form],
);
return (
<SegmentedControl
fullWidth
data={secretKindGroups}
defaultValue={defaultValue}
onChange={onChange}
></SegmentedControl>
);
};
@@ -18,7 +18,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]); const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
return ( return (
<Card style={{ borderColor: getMantineColor("red", 8) }}> <Card withBorder style={{ borderColor: getMantineColor("red", 8) }}>
<Stack> <Stack>
<Stack gap="sm"> <Stack gap="sm">
<Text size="lg" fw={500} c="red.8"> <Text size="lg" fw={500} c="red.8">
@@ -4,7 +4,8 @@ import { IconAlertTriangle, IconCheck, IconCopy, IconExclamationCircle, IconRepe
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { createId, getMantineColor } from "@homarr/common"; import { getMantineColor } from "@homarr/common";
import { createId } from "@homarr/db/client";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals"; import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection"; import { AddCertificateModal } from "@homarr/modals-collection";
@@ -29,8 +30,6 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation(); const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation(); const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
const rootCertificate = getHeighestCertificate(error.data.certificate);
const handleTrustHostname = () => { const handleTrustHostname = () => {
const { hostname } = new URL(url); const { hostname } = new URL(url);
openConfirmModal({ openConfirmModal({
@@ -73,7 +72,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
const formData = new FormData(); const formData = new FormData();
formData.append( formData.append(
"file", "file",
new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, { new File([error.data.certificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert", type: "application/x-x509-ca-cert",
}), }),
); );
@@ -111,11 +110,11 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
<> <>
{description} {description}
<CertificateDetailsCard certificate={rootCertificate} /> <CertificateDetailsCard certificate={error.data.certificate} />
{error.data.reason === "hostnameMismatch" && <HostnameMismatchAlert />} {error.data.reason === "hostnameMismatch" && <HostnameMismatchAlert />}
{!rootCertificate.isSelfSigned && error.data.reason === "untrusted" && <CertificateExtractAlert />} {!error.data.certificate.isSelfSigned && error.data.reason === "untrusted" && <CertificateExtractAlert />}
{showRetryButton && ( {showRetryButton && (
<Button <Button
@@ -128,7 +127,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
</Button> </Button>
)} )}
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) || {(error.data.reason === "untrusted" && error.data.certificate.isSelfSigned) ||
error.data.reason === "hostnameMismatch" ? ( error.data.reason === "hostnameMismatch" ? (
<Button <Button
variant="default" variant="default"
@@ -138,7 +137,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
{tError("certificate.action.trust.label")} {tError("certificate.action.trust.label")}
</Button> </Button>
) : null} ) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? ( {error.data.reason === "untrusted" && !error.data.certificate.isSelfSigned ? (
<Button <Button
variant="default" variant="default"
fullWidth fullWidth
@@ -216,7 +215,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
const tCertificateField = useScopedI18n("certificate.field"); const tCertificateField = useScopedI18n("certificate.field");
return ( return (
<Card> <Card withBorder>
<Text fw={500}>{tDetails("title")}</Text> <Text fw={500}>{tDetails("title")}</Text>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -326,8 +325,3 @@ const PemContentModal = createModal<{ content: string }>(({ actions, innerProps
}, },
size: "lg", size: "lg",
}); });
const getHeighestCertificate = (certificate: MappedCertificate): MappedCertificate => {
if (certificate.issuerCertificate) return getHeighestCertificate(certificate.issuerCertificate);
return certificate;
};
@@ -1,27 +1,23 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react"; import type { z } from "zod";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions"; import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { integrationUpdateSchema } from "@homarr/validation/integration"; import { integrationUpdateSchema } from "@homarr/validation/integration";
import { SecretCard } from "../../_components/secrets/integration-secret-card"; import { SecretCard } from "../../_components/secrets/integration-secret-card";
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs"; import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
import { SecretKindsSegmentedControl } from "../../_components/secrets/integration-secret-segmented-control";
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error"; import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types"; import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
@@ -29,41 +25,23 @@ interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"]; integration: RouterOutputs["integration"]["byId"];
} }
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
z.object({
app: z
.object({
id: z.string(),
name: z.string(),
iconUrl: z.string(),
href: z.string().nullable(),
})
.nullable(),
}),
);
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const t = useI18n(); const t = useI18n();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind); const secretsKinds =
const initialSecretsKinds =
getAllSecretKindOptions(integration.kind).find((secretKinds) => getAllSecretKindOptions(integration.kind).find((secretKinds) =>
integration.secrets.every((secret) => secretKinds.includes(secret.kind)), integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
) ?? getDefaultSecretKinds(integration.kind); ) ?? getDefaultSecretKinds(integration.kind);
const hasUrlSecret = initialSecretsKinds.includes("url");
const router = useRouter(); const router = useRouter();
const form = useZodForm(formSchema, { const form = useZodForm(integrationUpdateSchema.omit({ id: true }), {
initialValues: { initialValues: {
name: integration.name, name: integration.name,
url: integration.url, url: integration.url,
secrets: initialSecretsKinds.map((kind) => ({ secrets: secretsKinds.map((kind) => ({
kind, kind,
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "", value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})), })),
app: integration.app ?? null,
}, },
}); });
const { mutateAsync, isPending } = clientApi.integration.update.useMutation(); const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
@@ -71,20 +49,15 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async ({ app, ...values }: FormType) => { const handleSubmitAsync = async (values: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
await mutateAsync( await mutateAsync(
{ {
id: integration.id, id: integration.id,
...values, ...values,
url,
secrets: values.secrets.map((secret) => ({ secrets: values.secrets.map((secret) => ({
kind: secret.kind, kind: secret.kind,
value: secret.value === "" ? null : secret.value, value: secret.value === "" ? null : secret.value,
})), })),
appId: app?.id ?? null,
}, },
{ {
onSuccess: (data) => { onSuccess: (data) => {
@@ -114,80 +87,48 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
); );
}; };
const isInitialSecretKinds =
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
form.values.secrets.length === initialSecretsKinds.length;
return ( return (
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack> <Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} /> <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : ( <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{allSecretKinds.length > 1 && ( {secretsKinds.map((kind, index) => (
<SecretKindsSegmentedControl <SecretCard
defaultKinds={initialSecretsKinds} key={kind}
secretKinds={allSecretKinds} secret={secretsMap.get(kind) ?? { kind, value: null, updatedAt: null }}
form={form} onCancel={() =>
/> new Promise((resolve) => {
)} // When nothing changed, just close the secret card
{!isInitialSecretKinds if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
? null return resolve(true);
: form.values.secrets.map((secret, index) => (
<SecretCard
key={secret.kind}
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
return resolve(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
resolve(true);
},
});
})
} }
> openConfirmModal({
<IntegrationSecretInput title: t("integration.secrets.reset.title"),
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)} children: t("integration.secrets.reset.message"),
key={secret.kind} onCancel: () => resolve(false),
kind={secret.kind} onConfirm: () => {
{...form.getInputProps(`secrets.${index}.value`)} form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? "");
/> resolve(true);
</SecretCard> },
))} });
{isInitialSecretKinds })
? null }
: form.values.secrets.map(({ kind }, index) => ( >
<IntegrationSecretInput <IntegrationSecretInput
withAsterisk label={t(`integration.secrets.kind.${kind}.newLabel`)}
key={kind} key={kind}
kind={kind} kind={kind}
{...form.getInputProps(`secrets.${index}.value`)} {...form.getInputProps(`secrets.${index}.value`)}
/> />
))} </SecretCard>
{form.values.secrets.length === 0 && ( ))}
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
</Stack> </Stack>
</Fieldset> </Fieldset>
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />} {error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
<Group justify="end" align="center"> <Group justify="end" align="center">
@@ -203,80 +144,4 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
); );
}; };
type FormType = z.infer<typeof formSchema>; type FormType = Omit<z.infer<typeof integrationUpdateSchema>, "id">;
interface IntegrationAppSelectProps {
value: FormType["app"];
onChange: (app: FormType["app"]) => void;
}
const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
const handleChange = () =>
openModal(
{
onSelect: onChange,
withCreate: canCreateApps,
},
{
title: t("integration.page.edit.app.action.select"),
},
);
if (!value) {
return (
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
fullWidth
onClick={handleChange}
>
{t("integration.page.edit.app.action.add")}
</Button>
);
}
return (
<Fieldset legend={t("integration.field.app.sectionTitle")}>
<Group justify="space-between">
<Group gap="sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={value.iconUrl} alt={value.name} width={32} height={32} />
<Stack gap={0}>
<Text size="sm" fw="bold">
{value.name}
</Text>
{value.href !== null && (
<Anchor href={value.href} target="_blank" rel="noopener noreferrer" size="sm">
{value.href}
</Anchor>
)}
</Stack>
</Group>
<ButtonGroup>
<Button
variant="subtle"
color="gray"
leftSection={<IconUnlink size={16} stroke={1.5} />}
onClick={() => onChange(null)}
>
{t("integration.page.edit.app.action.remove")}
</Button>
<Button
variant="subtle"
color="gray"
leftSection={<IconPencil size={16} stroke={1.5} />}
onClick={handleChange}
>
{t("common.action.change")}
</Button>
</ButtonGroup>
</Group>
</Fieldset>
);
};
@@ -2,27 +2,23 @@
import type { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import Link from "next/link";
import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core"; import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar, Link } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
interface IntegrationCreateDropdownContentProps { export const IntegrationCreateDropdownContent = () => {
enableMockIntegration: boolean;
}
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
const t = useI18n(); const t = useI18n();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => { const filteredKinds = useMemo(() => {
return integrationKinds return integrationKinds.filter((kind) =>
.filter((kind) => enableMockIntegration || kind !== "mock") getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim())) );
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB))); }, [search]);
}, [search, enableMockIntegration]);
const handleSearch = React.useCallback( const handleSearch = React.useCallback(
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value), (event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
@@ -1,6 +1,7 @@
"use client"; "use client";
import { startTransition, useState } from "react"; import { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Alert, Alert,
@@ -9,38 +10,26 @@ import {
Collapse, Collapse,
Fieldset, Fieldset,
Group, Group,
Loader,
SegmentedControl, SegmentedControl,
Select,
Stack, Stack,
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { IconCheck, IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { Modify } from "@homarr/common/types"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions"; import { getAllSecretKindOptions, getIconUrl, getIntegrationName, integrationDefs } from "@homarr/definitions";
import { import type { UseFormReturnType } from "@homarr/form";
getAllSecretKindOptions,
getIconUrl,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
} from "@homarr/definitions";
import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { appHrefSchema } from "@homarr/validation/app"; import { appHrefSchema } from "@homarr/validation/app";
import { integrationCreateSchema } from "@homarr/validation/integration"; import { integrationCreateSchema } from "@homarr/validation/integration";
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs"; import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
import { SecretKindsSegmentedControl } from "../_components/secrets/integration-secret-segmented-control";
import { IntegrationTestConnectionError } from "../_components/test-connection/integration-test-connection-error"; import { IntegrationTestConnectionError } from "../_components/test-connection/integration-test-connection-error";
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types"; import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
@@ -50,77 +39,51 @@ interface NewIntegrationFormProps {
}; };
} }
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( const formSchema = integrationCreateSchema.omit({ kind: true }).and(
z.object({ z.object({
hasApp: z.boolean(), createApp: z.boolean(),
appHref: appHrefSchema, appHref: appHrefSchema,
appId: z.string().nullable(),
}), }),
); );
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind); const secretKinds = getAllSecretKindOptions(searchParams.kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const router = useRouter(); const router = useRouter();
const { data: session } = useSession(); const [opened, setOpened] = useState(false);
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
if (hasUrlSecret) {
// Placeholder Url, replaced with origin of the secret Url on submit
url = "http://localhost";
}
const form = useZodForm(formSchema, { const form = useZodForm(formSchema, {
initialValues: { initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind), name: searchParams.name ?? getIntegrationName(searchParams.kind),
url, url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({ secrets: secretKinds[0].map((kind) => ({
kind, kind,
value: "", value: "",
})), })),
attemptSearchEngineCreation: true, attemptSearchEngineCreation: true,
hasApp: false, createApp: false,
appHref: url, appHref: "",
appId: null, },
onValuesChange(values, previous) {
if (values.createApp !== previous.createApp) {
setOpened(values.createApp);
}
}, },
}); });
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({ const { mutateAsync: createIntegrationAsync, isPending: isPendingIntegration } =
async onSuccess() { clientApi.integration.create.useMutation();
await revalidatePathActionAsync("/manage/integrations"); const { mutateAsync: createAppAsync, isPending: isPendingApp } = clientApi.app.create.useMutation();
}, const isPending = isPendingIntegration || isPendingApp;
});
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null); const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => { const handleSubmitAsync = async (values: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
const app = hasApp
? appId !== null
? { id: appId }
: {
name: values.name,
href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(searchParams.kind),
description: null,
pingUrl: url,
}
: undefined;
await createIntegrationAsync( await createIntegrationAsync(
{ {
kind: searchParams.kind, kind: searchParams.kind,
...values, ...values,
url,
app,
}, },
{ {
onSuccess(data) { async onSuccess(data) {
// We do it this way as we are unable to send a typesafe error through onError // We do it this way as we are unable to send a typesafe error through onError
if (data?.error) { if (data?.error) {
setError(data.error); setError(data.error);
@@ -136,7 +99,32 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
message: t("integration.page.create.notification.success.message"), message: t("integration.page.create.notification.success.message"),
}); });
router.push("/manage/integrations"); if (!values.createApp) {
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
return;
}
const hasCustomHref = values.appHref !== null && values.appHref.trim().length >= 1;
await createAppAsync(
{
name: values.name,
href: hasCustomHref ? values.appHref : values.url,
iconUrl: getIconUrl(searchParams.kind),
description: null,
pingUrl: values.url,
},
{
async onSettled() {
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError() {
showErrorNotification({
title: t("app.page.create.notification.error.title"),
message: t("app.page.create.notification.error.message"),
});
},
},
);
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -155,9 +143,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
<Stack> <Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} /> <TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
{hasUrlSecret ? null : ( <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
@@ -190,7 +176,15 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
/> />
)} )}
<AppForm form={form} canCreateApps={canCreateApps} /> <Checkbox
{...form.getInputProps("createApp", { type: "checkbox" })}
label={t("integration.field.createApp.label")}
description={t("integration.field.createApp.description")}
/>
<Collapse in={opened}>
<TextInput placeholder={t("integration.field.appHref.placeholder")} {...form.getInputProps("appHref")} />
</Collapse>
<Group justify="end" align="center"> <Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations"> <Button variant="default" component={Link} href="/manage/integrations">
@@ -205,115 +199,40 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
); );
}; };
type FormType = z.infer<typeof formSchema>; interface SecretKindsSegmentedControlProps {
secretKinds: IntegrationSecretKind[][];
form: UseFormReturnType<FormType, (values: FormType) => FormType>;
}
const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; canCreateApps: boolean }) => { const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmentedControlProps) => {
const t = useI18n(); const t = useScopedI18n("integration.secrets");
const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" });
return ( const secretKindGroups = secretKinds.map((kinds) => ({
<> label:
<Checkbox kinds.length === 0
{...checkboxInputProps} ? t("noSecretsRequired.segmentTitle")
onChange={(event) => { : kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
startTransition(() => { value: kinds.length === 0 ? "empty" : kinds.join("-"),
form.setFieldValue("appHref", event.currentTarget.checked ? form.values.url : null); }));
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
checkboxInputProps.onChange(event);
});
}}
label={t("integration.field.createApp.label")}
description={t("integration.field.createApp.description")}
/>
<Collapse expanded={form.values.hasApp}> const onChange = useCallback(
<Fieldset legend={t("integration.field.app.sectionTitle")}> (value: string) => {
<Stack gap="sm"> if (value === "empty") {
{canCreateApps && ( form.setFieldValue("secrets", []);
<SegmentedControl return;
data={(["new", "existing"] as const).map((value) => ({
value,
label: t(`integration.page.create.app.option.${value}.title`),
}))}
value={form.values.appHref === null ? "existing" : "new"}
onChange={(value) => {
if (value === "existing") {
form.setFieldValue("appId", null);
form.setFieldValue("appHref", null);
} else {
form.setFieldValue("appId", null);
form.setFieldValue("appHref", form.values.url);
}
}}
/>
)}
{typeof form.values.appHref === "string" && canCreateApps ? (
<TextInput
placeholder={t("integration.field.appHref.placeholder")}
withAsterisk
label={t("integration.page.create.app.option.new.url.label")}
description={t("integration.page.create.app.option.new.url.description")}
{...form.getInputProps("appHref")}
/>
) : (
<IntegrationAppSelect {...form.getInputProps("appId")} />
)}
</Stack>
</Fieldset>
</Collapse>
</>
);
};
type IntegrationAppSelectProps = Modify<
GetInputPropsReturnType,
{
value?: string | null;
onChange: (value: string | null) => void;
}
>;
const IntegrationAppSelect = ({ value, ...props }: IntegrationAppSelectProps) => {
const { data, isPending } = clientApi.app.selectable.useQuery();
const t = useI18n();
const appMap = new Map(data?.map((app) => [app.id, app] as const));
return (
<Select
withAsterisk
label={t("integration.page.create.app.option.existing.label")}
searchable
clearable
leftSection={
// eslint-disable-next-line @next/next/no-img-element
value ? <img width={20} height={20} src={appMap.get(value)?.iconUrl} alt={appMap.get(value)?.name} /> : null
} }
renderOption={({ option, checked }) => (
<Group flex="1" gap="xs"> const kinds = value.split("-") as IntegrationSecretKind[];
{/* eslint-disable-next-line @next/next/no-img-element */} const secrets = kinds.map((kind) => ({
<img width={20} height={20} src={appMap.get(option.value)?.iconUrl} alt={option.label} /> kind,
<Stack gap={0}> value: "",
<Text>{option.label}</Text> }));
<Text size="xs" c="dimmed"> form.setFieldValue("secrets", secrets);
{appMap.get(option.value)?.href} },
</Text> [form],
</Stack>
{checked && (
<IconCheck
style={{ marginInlineStart: "auto" }}
stroke={1.5}
color="currentColor"
opacity={0.6}
size={18}
/>
)}
</Group>
)}
{...props}
data={data?.map((app) => ({ value: app.id, label: app.name }))}
rightSection={isPending ? <Loader size="sm" /> : null}
/>
); );
return <SegmentedControl fullWidth data={secretKindGroups} onChange={onChange}></SegmentedControl>;
}; };
type FormType = z.infer<typeof formSchema>;
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Container, Group, Stack, Title } from "@mantine/core"; import { Container, Group, Stack, Title } from "@mantine/core";
import { z } from "zod/v4"; import { z } from "zod";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
@@ -8,7 +8,7 @@
border: 1px solid transparent; border: 1px solid transparent;
position: relative; position: relative;
z-index: 0; z-index: 0;
transition: background-color 150ms ease; transition: transform 150ms ease;
overflow: hidden; overflow: hidden;
&[data-first="true"] { &[data-first="true"] {
@@ -20,9 +20,11 @@
} }
&[data-active] { &[data-active] {
transform: scale(1.01);
z-index: 1; z-index: 1;
background-color: var(--mantine-color-body); background-color: var(--mantine-color-body);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-lg); border-radius: var(--mantine-radius-lg);
} }
} }
@@ -1,5 +1,6 @@
import { Fragment } from "react"; import { Fragment } from "react";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
AccordionControl, AccordionControl,
@@ -7,7 +8,9 @@ import {
AccordionPanel, AccordionPanel,
ActionIcon, ActionIcon,
ActionIconGroup, ActionIconGroup,
Affix,
Anchor, Anchor,
Box,
Button, Button,
Divider, Divider,
Group, Group,
@@ -22,8 +25,9 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -32,12 +36,11 @@ import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui"; import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManageContainer } from "~/components/manage/manage-container";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { env } from "~/env";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -63,23 +66,38 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const canCreateIntegrations = session.user.permissions.includes("integration-create"); const canCreateIntegrations = session.user.permissions.includes("integration-create");
return ( return (
<ManagePageLayout <ManageContainer>
title={t("page.list.title")} <DynamicBreadcrumb />
primaryAction={ <Stack>
canCreateIntegrations ? ( <Group justify="space-between" align="center">
<ManageMobilePrimaryAction> <Title>{t("page.list.title")}</Title>
<IntegrationSelectMenu>
<MenuTarget> {canCreateIntegrations && (
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button> <>
</MenuTarget> <Box>
</IntegrationSelectMenu> <IntegrationSelectMenu>
</ManageMobilePrimaryAction> <Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
) : undefined <MenuTarget>
} <Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
floatingPrimaryAction={canCreateIntegrations} </MenuTarget>
> </Affix>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} /> </IntegrationSelectMenu>
</ManagePageLayout> </Box>
<Box visibleFrom="md">
<IntegrationSelectMenu>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</IntegrationSelectMenu>
</Box>
</>
)}
</Group>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</Stack>
</ManageContainer>
); );
} }
@@ -96,7 +114,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
> >
{children} {children}
<MenuDropdown> <MenuDropdown>
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} /> <IntegrationCreateDropdownContent />
</MenuDropdown> </MenuDropdown>
</Menu> </Menu>
); );
@@ -14,7 +14,7 @@ export const UploadMediaButton = () => {
}; };
return ( return (
<UploadMedia onSettled={onSettledAsync} multiple> <UploadMedia onSettled={onSettledAsync}>
{({ onClick, loading }) => ( {({ onClick, loading }) => (
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}> <Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")} {t("media.action.upload.label")}
@@ -1,19 +1,22 @@
import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
ActionIcon, ActionIcon,
Anchor, Anchor,
Group, Group,
Image, Image,
Stack,
Table, Table,
TableTbody, TableTbody,
TableTd, TableTd,
TableTh, TableTh,
TableThead, TableThead,
TableTr, TableTr,
Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconExternalLink, IconPhoto } from "@tabler/icons-react"; import { IconExternalLink } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -22,11 +25,10 @@ import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local"; import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui"; import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManageContainer } from "~/components/manage/manage-container";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { CopyMedia } from "./_actions/copy-media"; import { CopyMedia } from "./_actions/copy-media";
import { DeleteMedia } from "./_actions/delete-media"; import { DeleteMedia } from "./_actions/delete-media";
import { IncludeFromAllUsersSwitch } from "./_actions/show-all"; import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
@@ -47,7 +49,7 @@ interface MediaListPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>; searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
} }
export default async function MediaListPage(props: MediaListPageProps) { export default async function GroupsListPage(props: MediaListPageProps) {
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
@@ -57,31 +59,22 @@ export default async function MediaListPage(props: MediaListPageProps) {
const t = await getI18n(); const t = await getI18n();
const searchParams = searchParamsSchema.parse(await props.searchParams); const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
const canUpload = session.user.permissions.includes("media-upload");
return ( return (
<ManagePageLayout <ManageContainer>
title={t("media.plural")} <DynamicBreadcrumb />
primaryAction={ <Stack>
canUpload ? ( <Title>{t("media.plural")}</Title>
<ManageMobilePrimaryAction> <Group justify="space-between">
<UploadMediaButton /> <Group>
</ManageMobilePrimaryAction> <SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
) : undefined {session.user.permissions.includes("media-view-all") && (
} <IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
toolbar={ )}
<Group> </Group>
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("media-view-all") && ( {session.user.permissions.includes("media-upload") && <UploadMediaButton />}
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
)}
</Group> </Group>
}
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
floatingPrimaryAction={canUpload}
>
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />}
{medias.length > 0 && (
<Table striped highlightOnHover> <Table striped highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
@@ -98,8 +91,13 @@ export default async function MediaListPage(props: MediaListPageProps) {
))} ))}
</TableTbody> </TableTbody>
</Table> </Table>
)}
</ManagePageLayout> {/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
); );
} }
@@ -7,7 +7,9 @@ export default async function NotFound() {
return ( return (
<Center h="100%"> <Center h="100%">
<Stack align="center"> <Stack align="center">
<Title order={1}>{t("title")}</Title> <Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text> <Text>{t("text")}</Text>
</Stack> </Stack>
</Center> </Center>
+76 -21
View File
@@ -1,16 +1,25 @@
import type { Metadata } from "next"; import Link from "next/link";
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core"; import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner"; import { HeroBanner } from "./_components/hero-banner";
export async function generateMetadata(): Promise<Metadata> { interface LinkProps {
title: string;
subtitle: string;
count: number;
href: string;
hidden?: boolean;
}
export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getScopedI18n("management");
return { return {
@@ -20,32 +29,78 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function ManagementPage() { export default async function ManagementPage() {
const statistics = await api.home.getStats(); const statistics = await api.home.getStats();
const session = await auth();
const t = await getScopedI18n("management.page.home"); const t = await getScopedI18n("management.page.home");
const links: LinkProps[] = [
{
count: statistics.countBoards,
href: "/manage/boards",
subtitle: t("statisticLabel.boards"),
title: t("statistic.board"),
},
{
count: statistics.countUsers,
href: "/manage/users",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.user"),
hidden: !session?.user.permissions.includes("admin"),
},
{
count: statistics.countInvites,
href: "/manage/users/invites",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.invite"),
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
},
{
count: statistics.countIntegrations,
href: "/manage/integrations",
subtitle: t("statisticLabel.resources"),
title: t("statistic.integration"),
},
{
count: statistics.countApps,
href: "/manage/apps",
subtitle: t("statisticLabel.resources"),
title: t("statistic.app"),
hidden: !session?.user,
},
{
count: statistics.countGroups,
href: "/manage/users/groups",
subtitle: t("statisticLabel.authorization"),
title: t("statistic.group"),
hidden: !session?.user.permissions.includes("admin"),
},
];
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<HeroBanner /> <HeroBanner />
<Space h="md" /> <Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}> <SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{statistics.map((statistic) => ( {links.map(
<Card component={Link} href={statistic.path} key={statistic.path}> (link) =>
<Group justify="space-between" wrap="nowrap"> !link.hidden && (
<Group wrap="nowrap"> <Card component={Link} href={link.href} key={link.href} radius="lg">
<Text size="xl" fw={700}> <Group justify="space-between" wrap="nowrap">
{statistic.count} <Group wrap="nowrap">
</Text> <Text size="2.4rem" fw="bolder">
<Stack gap={0}> {link.count}
<Text c="dimmed" size="xs"> </Text>
{t(`statisticLabel.${statistic.subtitleKey}`)} <Stack gap={0}>
</Text> <Text c="red" size="xs">
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text> {link.subtitle}
</Stack> </Text>
</Group> <Text fw="bold">{link.title}</Text>
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" /> </Stack>
</Group> </Group>
</Card> <IconArrowRight />
))} </Group>
</Card>
),
)}
</SimpleGrid> </SimpleGrid>
</> </>
); );
@@ -1,9 +1,10 @@
"use client"; "use client";
import Link from "next/link";
import type { SegmentedControlItem } from "@mantine/core"; import type { SegmentedControlItem } from "@mantine/core";
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core"; import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select"; import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
import type { z } from "zod/v4"; import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions"; import { searchEngineTypes } from "@homarr/definitions";
@@ -11,7 +12,6 @@ import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection"; import { IconPicker } from "@homarr/forms-collection";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { searchEngineManageSchema } from "@homarr/validation/search-engine"; import { searchEngineManageSchema } from "@homarr/validation/search-engine";
type FormType = z.infer<typeof searchEngineManageSchema>; type FormType = z.infer<typeof searchEngineManageSchema>;
@@ -2,7 +2,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { z } from "zod/v4"; import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -2,7 +2,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { z } from "zod/v4"; import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -1,17 +1,19 @@
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react"; import { IconPencil, IconSearch } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { SearchInput, TablePagination } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button"; import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
@@ -37,31 +39,34 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const tEngine = await getScopedI18n("search.engine"); const tEngine = await getScopedI18n("search.engine");
const canCreate = session.user.permissions.includes("search-engine-create");
return ( return (
<ManagePageLayout <ManageContainer>
title={tEngine("page.list.title")} <DynamicBreadcrumb />
primaryAction={ <Stack>
canCreate ? ( <Title>{tEngine("page.list.title")}</Title>
<MobileAffixButton component={Link} href="/manage/search-engines/new"> <Group justify="space-between" align="center">
{tEngine("page.create.title")} <SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
</MobileAffixButton> {session.user.permissions.includes("search-engine-create") && (
) : undefined <MobileAffixButton component={Link} href="/manage/search-engines/new">
} {tEngine("page.create.title")}
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />} </MobileAffixButton>
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />} )}
floatingPrimaryAction={canCreate} </Group>
> {searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length === 0 && <SearchEngineNoResults />} {searchEngines.length > 0 && (
{searchEngines.length > 0 && ( <Stack gap="sm">
<Stack gap="sm"> {searchEngines.map((searchEngine) => (
{searchEngines.map((searchEngine) => ( <SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} /> ))}
))} </Stack>
</Stack> )}
)}
</ManagePageLayout> {/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
); );
} }
@@ -74,7 +79,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const session = await auth(); const session = await auth();
return ( return (
<Card> <Card withBorder>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}> <Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
<Avatar <Avatar
@@ -24,6 +24,15 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
return; return;
} }
if (
!updatedValues.enableGeneral &&
(updatedValues.enableWidgetData || updatedValues.enableIntegrationData || updatedValues.enableUserData)
) {
updatedValues.enableIntegrationData = false;
updatedValues.enableUserData = false;
updatedValues.enableWidgetData = false;
}
void mutateAsync({ void mutateAsync({
settingsKey: "analytics", settingsKey: "analytics",
value: updatedValues, value: updatedValues,
@@ -41,10 +50,34 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
<> <>
<Title order={2}>{t("title")}</Title> <Title order={2}>{t("title")}</Title>
<Card pos="relative"> <Card pos="relative" withBorder>
<LoadingOverlay visible={isPending} /> <LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack> <Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} /> <SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
<SwitchSetting
form={form}
formKey="enableIntegrationData"
ms="xl"
title={t("integrationData.title")}
text={t("integrationData.text")}
disabled={!form.values.enableGeneral}
/>
<SwitchSetting
form={form}
formKey="enableWidgetData"
ms="xl"
title={t("widgetData.title")}
text={t("widgetData.text")}
disabled={!form.values.enableGeneral}
/>
<SwitchSetting
form={form}
formKey="enableUserData"
ms="xl"
title={t("usersData.title")}
text={t("usersData.text")}
disabled={!form.values.enableGeneral}
/>
</Stack> </Stack>
</Card> </Card>
</> </>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Group, Text } from "@mantine/core"; import { Group, Text } from "@mantine/core";
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react"; import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions"; import { colorSchemes } from "@homarr/definitions";
@@ -34,7 +34,6 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
}; };
const appearanceIcons = { const appearanceIcons = {
auto: IconDeviceDesktop,
light: IconSun, light: IconSun,
dark: IconMoon, dark: IconMoon,
}; };
Loaded 100 of 1088 files, more files were not shown because too many files have changed in this diff. Show more