Compare commits

..
Author SHA1 Message Date
ajnart b218910476 fix(media-requests): cache statistics responses 2026-07-22 17:36:48 +02:00
Thomas cf0e469465 feat: restrict apps management to board modifiers (#6425) 2026-07-21 16:56:33 +02:00
Thomas 8e7fa782b3 feat(weather): add humidity display (#6429) 2026-07-21 16:36:03 +02:00
Thomas 97652802f0 feat(immich): show random photos without album (#6424) 2026-07-21 16:10:45 +02:00
Thomas 1706289124 fix(media-server): guard against undefined playback from stale cached data (#6433) 2026-07-21 16:01:37 +02:00
homarr-renovate[bot] dd23de7aaa chore(deps): update pnpm to v11.13.1 (#6422)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-21 05:54:52 +00:00
Thomas f1602f67d4 fix: stop auto-seeding ArchiveTeam Warrior integration and widget on default dashboards (#6413) 2026-07-20 17:01:07 +02:00
Thomas c03cd97d3a fix(test): use Home Assistant long-lived token JWT
* fix(test): use Home Assistant long-lived token

* fix(test): revert to correct Home Assistant long-lived token JWT
2026-07-20 15:27:50 +02:00
Thomas a8e9a074d3 Merge pull request #6411 from homarr-labs/mysql-migration-test-timeout
fix(db): increase mysql migration test timeout to 120s
2026-07-20 15:19:12 +02:00
Thomas Camlong 1e9d3e0656 fix(db): increase mysql migration test timeout to 120s 2026-07-20 15:18:50 +02:00
Thomas 01e0f3612d docs(immich): list Asset View permission (#6408) 2026-07-20 14:57:07 +02:00
Thomas 0015a6d88d fix(immich): use thumbnail endpoint instead of original for album carousel (#6396)
* fix(immich): use thumbnail endpoint instead of original for album carousel

Switches from /api/assets/:id/original to /api/assets/:id/thumbnail to fix
HEIC image incompatibility (thumbnails are always JPEG), reduce bandwidth,
and improve loading speed.

Closes #6388

* working on immich

* Improve image proxy error handling

* fix(image-proxy): use trusted fetch response type

* docs(immich): require Asset View permission
2026-07-20 14:53:27 +02:00
Thomas 77dc317da2 chore(ci): remove memory regression tests (#6395) 2026-07-18 22:55:38 +02:00
homarr-update-contributors[bot] 6aeda3faaf chore: update contributors (#6378) 2026-07-18 22:43:01 +02:00
Releases Homarr ca358d9f8b chore(release): 1.71.0 [skip ci] 2026-07-17 17:58:17 +00:00
homarr-releases[bot] 03e0683892 chore(release): automatic release v1.71.0 2026-07-17 17:57:04 +00:00
Junker der Provinz 6ac4c6a7b9 fix(search): let public dashboards use generic custom search engines (#6375) 2026-07-17 14:53:52 +02:00
Junker der Provinz a0e0bf65ff fix(releases): let LinuxServer.io version filters find older releases via GitHub (#6376) 2026-07-17 14:53:01 +02:00
Junker der Provinz bc018e1fe0 fix(docker): disable the broken full-screen toggle on the container table (#6374) 2026-07-17 14:43:37 +02:00
Thomas 5e39c8813a feat(preferences): add enableRightClickOnWidgets user preference (#6298) 2026-07-17 14:42:19 +02:00
Thomas 06719cdef0 fix(ci): remove coverage report, scope format to changed files, fix oxlint config (#6359) 2026-07-16 10:21:07 +02:00
Bergasha 5144e14da4 fix(media-server): populate playback progress, transcode status, and network location for streams (#6315) 2026-07-15 16:27:58 +02:00
Thomas 8528d5068b feat(media-requests): filter requests list by status and time window (#6320) 2026-07-15 16:27:24 +02:00
Thomas c6c4428f7d Revert "fix(docker): report current CPU usage instead of the lifetime average" (#6357) 2026-07-15 14:55:18 +02:00
Thomas 7e4cb0201b fix(widgets): add refetchInterval to WidgetDefinition for per-widget polling (#6352) 2026-07-15 11:52:35 +02:00
Thomas 7887ce12c0 fix(integrations): resolve intermittent Seerr/Overseerr crashes and add query status to context menu (#6347) 2026-07-15 11:41:18 +02:00
Thomas 2d450239ac fix(releases): add required octokit throttling rate limit handlers (#6338) 2026-07-15 11:27:32 +02:00
Thomas bf4ccd8cb3 fix(ci): remove persist-credentials: false to fix git-auto-commit-action push (#6346) 2026-07-15 10:33:32 +02:00
homarr-renovate[bot] c473e80243 chore(deps): update pnpm to v11.11.0 (#6342)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-15 02:31:21 +00:00
homarr-renovate[bot] bf55bcce92 fix(deps): update dependency swiper to ^14.0.5 (#6341)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 23:55:02 +00:00
homarr-renovate[bot] 49248cedaf chore(deps): update dependency posthog-js to ^1.399.1 (#6340)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 22:09:52 +00:00
homarr-renovate[bot] a0034b2cfc chore(deps): update dependency @immich/sdk to ^3.0.2 (#6339)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 21:01:58 +00:00
homarr-renovate[bot] 4a297180fd chore(deps): update dependency lucide-react to ^1.24.0 (#6334)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 19:23:26 +00:00
Thomas 5fa3a3313a Merge pull request #6337 from homarr-labs/fix/ts7-nextjs-build
fix(docker): pin TypeScript to v6 to fix Next.js 16.2.10 build in CI
2026-07-14 18:11:38 +02:00
Thomas Camlong 29ee87c4aa fix(docker): pin typescript to v6 via alias to fix Next.js 16.2.10 build 2026-07-14 18:11:15 +02:00
Thomas 94a247e24a fix(docker): remove --offline flag from pnpm install step (#6335) 2026-07-14 17:19:51 +02:00
homarr-renovate[bot] 6d37710fec chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.1.4 (#6333)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 11:26:55 +00:00
homarr-renovate[bot] c016beb2e5 chore(deps): update dependency vite to ^8.1.4 (#6332)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 09:06:17 +00:00
homarr-renovate[bot] f9e1b85c6a chore(deps): update dependency posthog-node to ^5.40.0 (#6314)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 06:14:41 +00:00
homarr-renovate[bot] 38ba19e665 chore(deps): update dependency oxlint to ^1.73.0 (#6306)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 03:22:16 +00:00
homarr-renovate[bot] f55c99d2d6 chore(deps): update dependency posthog-js to ^1.399.0 (#6331)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 00:00:38 +00:00
homarr-renovate[bot] c5e8b83d8a chore(deps): update vitest monorepo to ^4.1.10 (#6286)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 23:01:00 +00:00
homarr-renovate[bot] d52d9cfed2 chore(deps): update dependency posthog-js to ^1.398.7 (#6319)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 21:58:26 +00:00
Thomas 93325642aa Merge pull request #6324 from homarr-labs/renovate/major-github-artifact-actions 2026-07-13 22:12:32 +02:00
Thomas 165e94abbc Merge pull request #6328 from homarr-labs/fix-pnpm-fetch-build 2026-07-13 22:12:11 +02:00
homarr-renovate[bot] 2e3127e766 chore(deps): update docker/dockerfile docker tag to v1.25 (#6321) 2026-07-13 22:10:35 +02:00
Thomas d37326d905 Merge pull request #6322 from homarr-labs/renovate/actions-cache-6-x 2026-07-13 22:09:57 +02:00
homarr-renovate[bot] e51e39ff38 chore(deps): update dependency typescript to v7 (#6325) 2026-07-13 22:09:30 +02:00
homarr-renovate[bot] 6c075b6d74 chore(deps): update dependency mysql2 to v3.22.6 (#6310)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 18:45:08 +00:00
ajnart b2f63c6e0f fix(docker): drop --frozen-lockfile from install step per pnpm fetch docs
The pnpm fetch docs recommend `pnpm install -r --offline` without
--frozen-lockfile. The --offline flag already enforces no registry
access, making --frozen-lockfile redundant.

https://pnpm.io/cli/fetch
2026-07-13 20:24:59 +02:00
ajnart 8c2d73e8af fix(docker): resolve pnpm fetch crash with patchedDependencies
PR #6296 introduced pnpm fetch in the Dockerfile for Docker cache
optimization. However, pnpm fetch crashes with ENOENT when
patchedDependencies are configured with nodeLinker: hoisted because
applyPatchToDir tries to chdir into node_modules/<pkg> which doesn't
exist during fetch (only the content-addressable store is populated).

Fix: temporarily switch nodeLinker to isolated during pnpm fetch so
patches apply inside the virtual store (node_modules/.pnpm/...) which
IS created by pnpm fetch. Restore hoisted before the install step so
the final node_modules layout stays flat.

Refs: pnpm/pnpm#5268, pnpm/pnpm#8726
2026-07-13 20:06:28 +02:00
homarr-renovate[bot] 1c0ff8930e chore(deps): update actions/upload-artifact action to v7 2026-07-13 16:39:27 +00:00
homarr-renovate[bot] e2b8355639 chore(deps): update actions/cache action to v6 2026-07-13 16:39:10 +00:00
147 changed files with 8514 additions and 4445 deletions

No files matched your search

+1 -3
View File
@@ -66,6 +66,4 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
# Set to false for writable preview deployments so the demo user can modify the dashboard.
# DEMO_READ_ONLY=false
# Central Homarr Workshop API. Override only when developing the Workshop locally.
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
# DEMO_READ_ONLY=false
-1
View File
@@ -23,7 +23,6 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
persist-credentials: false
- name: Setup
uses: ./tooling/github/setup
+9 -92
View File
@@ -60,7 +60,7 @@ jobs:
TURBO_PLATFORM: linux/amd64
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
if: "!env.ACT"
with:
fetch-depth: 0
persist-credentials: false
@@ -81,9 +81,6 @@ jobs:
- name: Copy environment defaults
run: cp .env.example .env
- name: Check formatting in affected workspaces
run: pnpm turbo run format --affected --continue -- --check
- name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
@@ -93,12 +90,8 @@ jobs:
- name: Run unit tests
run: pnpm test
- name: Report coverage
if: always() && !env.ACT
uses: davelosert/vitest-coverage-report-action@v2
container:
name: Container, E2E, and memory
name: Container and E2E
needs: fast-gate
if: |
github.event_name == 'merge_group' ||
@@ -111,7 +104,7 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
if: "!env.ACT"
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
@@ -120,11 +113,11 @@ jobs:
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: '!env.ACT'
if: "!env.ACT"
uses: docker/setup-buildx-action@v4
- name: Build container once
if: '!env.ACT'
if: "!env.ACT"
uses: docker/build-push-action@v7
with:
context: .
@@ -165,9 +158,9 @@ jobs:
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: '!env.ACT'
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@@ -185,88 +178,12 @@ jobs:
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
- name: Run memory regression test
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
run: |
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
-e NO_EXTERNAL_CONNECTION=true \
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
homarr-ci
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
URL="http://127.0.0.1:${PORT}"
healthy=false
for i in $(seq 1 30); do
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
warmup_ok=0
for _ in $(seq 1 5); do
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
sleep 2
done
if [ "$warmup_ok" -eq 0 ]; then
echo "ERROR: All warmup requests failed"
docker logs "$CONTAINER_NAME" --tail 50
exit 1
fi
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
echo "ERROR: Container died before memory measurement"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
/MiB/ { gsub(/MiB/, ""); print $1; next }
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
{ print $1 / 1048576 }
')
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
- name: Clean up memory test container
if: always()
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
- name: Determine preview tag
id: preview
if: |
@@ -316,7 +233,7 @@ jobs:
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version
if: '!env.ACT'
if: "!env.ACT"
uses: actions/github-script@v8
with:
script: |
+2
View File
@@ -1,3 +1,5 @@
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
+15 -3
View File
@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.10
# syntax=docker/dockerfile:1.25
FROM node:24.18.0-alpine AS base
@@ -9,12 +9,24 @@ RUN apk add --no-cache libc6-compat curl bash && apk update
RUN corepack enable pnpm
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
# content-addressable store is populated). By temporarily switching to the
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
# which IS created by pnpm fetch. The original hoisted linker is restored
# before the install step so the final node_modules layout stays flat.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && pnpm fetch
pnpm config set store-dir /pnpm/store && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
COPY . .
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive --offline --frozen-lockfile
pnpm install --recursive
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
-3
View File
@@ -23,9 +23,6 @@
"react-hooks/exhaustive-deps": "warn",
"eslint/no-unused-expressions": "warn"
},
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
"settings": {
"react": { "version": "19" }
+1 -2
View File
@@ -50,12 +50,11 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
<code>user.read</code>,{" "}
<code>user.update</code>,{" "}
<code>asset.read</code>,{" "}
<code>asset.view</code>,{" "}
<code>asset.download</code>,{" "}
<code>asset.statistics</code>,{" "}
<code>server.statistics</code>,{" "}
<code>server.about</code>.
<br />
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
</span>
),
@@ -82,10 +82,6 @@ Custom widgets can be exported and imported as JSON files. To export, select the
The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version.
## Community Workshop
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. Use **Share in Workshop** from a widget's action menu to publish its canonical export without manually copying JSON. Secrets are never included in exports and must be configured on each Homarr instance.
## Available Mantine Components for Custom JSX
When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security.
@@ -1,40 +0,0 @@
---
tags:
- Workshop
- Custom Widgets
- Custom CSS
---
# Community Workshop
The [Workshop](pathname:///workshop) is Homarr's community catalog for custom widgets and CSS themes. You can inspect every creation before downloading it or using it in Homarr.
:::warning Community content
Workshop entries are created by community members. Review widget endpoints, templates, and CSS before using them. Keep a current Homarr backup and never add secrets to an exported widget.
:::
## Discover and install
In Homarr, open **Management → Workshop** to browse, search, filter, inspect, download, and install creations. The custom-widget management page and a board's custom CSS settings also provide contextual Workshop actions.
- Widgets are validated against `homarr-custom-widget-v2` before installation. API keys and other secrets are not included and must be configured locally.
- CSS is loaded into the board editor for review. It is not saved until you choose **Save changes**.
- The public website never executes submitted widget templates or CSS.
## Share a creation
Use **Share in Workshop** beside a custom widget or in a board's custom CSS settings. Sign in with GitHub, provide a public title and description, inspect the exported source, and publish it. You can also publish from the Workshop website by pasting an exported widget or CSS source.
Submissions are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
## Votes and reports
Signed-in members can vote to help useful creations rank higher. Report malicious content, spam, copyright problems, or inappropriate material from a submission's detail page. Reports are visible only to Workshop staff.
## Accounts and moderation
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. Workshop moderators can remove submissions, prevent an account from publishing, disable an account, and resolve reports. These actions require a reason and are recorded.
The Workshop is a central service operated by Homarr at `store.homarr.dev`. Running the backend locally is supported for contributors developing or testing this feature, not as a private production marketplace.
@@ -1,6 +1,6 @@
---
title: 'Immich Album Carousel'
description: 'Shows a slideshow of your album pictures from Immich'
description: 'Shows a slideshow of pictures from an Immich album or your full library'
hide_title: true
---
@@ -37,4 +37,3 @@ import screen from './img/screen.png';
### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,15 +4,16 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto,
name: "Immich Album",
description: "Shows a slideshow of your album pictures from Immich",
description: "Shows a slideshow of pictures from an Immich album or your full library",
path: "../../widgets/immich-album-carousel",
configuration: {
items: [
{
name: "Album",
description: "Select the Immich album to show in the slideshow.",
description:
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
values: { type: "string" },
defaultValue: "",
defaultValue: "All photos",
},
{
name: "Interval in seconds",
@@ -1,41 +1,44 @@
---
title: 'Media Request List'
description: 'See a list of all media requests from your integration'
title: "Media Request List"
description: "See a list of all media requests from your integration"
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { mediaRequestListWidget } from '.';
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
import { overseerrIntegration } from '@site/docs/integrations/overseerr';
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader
widget={mediaRequestListWidget}
categories={['Media requests']}
/>
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
### Screenshots
import mediaRequestList from './img/list.png';
import mediaRequestList from "./img/list.png";
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations
<WidgetIntegrations items={[{
integration: jellyseerrIntegration,
}, {
integration: overseerrIntegration,
}]} />
<WidgetIntegrations
items={[
{
integration: jellyseerrIntegration,
},
{
integration: overseerrIntegration,
},
]}
/>
### Interactions
@@ -47,4 +50,5 @@ import mediaRequestList from './img/list.png';
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,6 +14,18 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Statuses to show",
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
defaultValue: "All statuses",
},
{
name: "Show only recent requests",
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
values: "0-365",
defaultValue: "0",
},
],
},
};
-20
View File
@@ -16,9 +16,6 @@ const config: Config = {
defaultLocale: "en",
locales: ["en"],
},
customFields: {
workshopApiUrl: process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://store.homarr.dev",
},
onBrokenLinks: "throw",
onBrokenAnchors: "throw",
onDuplicateRoutes: "throw",
@@ -127,11 +124,6 @@ const config: Config = {
position: "left",
to: "/blog",
},
{
label: "Workshop",
position: "left",
to: "/workshop",
},
{
label: "About us",
position: "left",
@@ -317,18 +309,6 @@ const config: Config = {
},
};
},
function workshopRoutesPlugin() {
return {
name: "workshop-routes",
async contentLoaded({ actions }) {
actions.addRoute({
path: "/workshop/:id",
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
exact: true,
});
},
};
},
"@signalwire/docusaurus-plugin-llms-txt",
async function tailwindCssPlugin() {
return {
+1 -2
View File
@@ -27,7 +27,6 @@
"@docusaurus/plugin-sitemap": "^3.10.1",
"@docusaurus/preset-classic": "^3.10.1",
"@homarr/definitions": "workspace:^",
"@homarr/workshop": "workspace:^0.1.0",
"@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:",
@@ -44,7 +43,7 @@
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.2",
"swiper": "^14.0.5",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
@@ -1,300 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import {
WORKSHOP_API_URL,
WorkshopClient,
type WorkshopAccountState,
type WorkshopModerationAction,
type WorkshopReport,
type WorkshopRole,
type WorkshopUser,
} from "@homarr/workshop";
import styles from "./workshop.module.css";
type PendingAction =
| { kind: "report"; id: string; status: "resolved" | "dismissed" }
| { kind: "state"; id: string; state: WorkshopAccountState }
| { kind: "role"; id: string; role: WorkshopRole };
export function WorkshopAdmin() {
const { siteConfig } = useDocusaurusContext();
const client = useMemo(
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
[siteConfig],
);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [users, setUsers] = useState<WorkshopUser[]>([]);
const [actions, setActions] = useState<WorkshopModerationAction[]>([]);
const [pending, setPending] = useState<PendingAction | null>(null);
const [error, setError] = useState("");
const load = useCallback(async () => {
const current = await client.refreshAuth();
setUser(current);
if (!current || (current.role !== "moderator" && current.role !== "admin")) return;
try {
const [nextReports, nextUsers, nextActions] = await Promise.all([
client.listReports(),
client.listUsers(),
client.listModerationActions(),
]);
setReports(nextReports);
setUsers(nextUsers);
setActions(nextActions);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not load moderation data");
}
}, [client]);
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void load();
}, [load]);
if (!user)
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<p>Sign in with a promoted GitHub account to continue.</p>
<button
className="button button--primary"
onClick={async () => {
await client.signInWithGitHub();
await load();
}}
>
Sign in with GitHub
</button>
</main>
);
if (user.role === "member")
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<div className={styles.error} role="alert">
Your account does not have moderation access.
</div>
</main>
);
return (
<main className={styles.page}>
<header className={styles.hero}>
<div>
<h1>Workshop moderation</h1>
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
</div>
</header>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<section className={styles.adminSection}>
<h2>Open reports</h2>
{reports.length === 0 ? (
<p>No open reports.</p>
) : (
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>Category</th>
<th>Submission</th>
<th>Details</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{reports.map((report) => (
<tr key={report.id}>
<td>{report.category}</td>
<td>{report.submission}</td>
<td>{report.explanation}</td>
<td>{new Date(report.created).toLocaleString()}</td>
<td aria-label="Report actions">
<div className={styles.rowActions}>
<button onClick={() => setPending({ kind: "report", id: report.id, status: "resolved" })}>
Resolve
</button>
<button onClick={() => setPending({ kind: "report", id: report.id, status: "dismissed" })}>
Dismiss
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className={styles.adminSection}>
<h2>Community accounts</h2>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>State</th>
<th>Moderate</th>
</tr>
</thead>
<tbody>
{users.map((account) => (
<tr key={account.id}>
<td>{account.displayName}</td>
<td>{account.role}</td>
<td>{account.state}</td>
<td>
{account.id === user.id ? (
"Current account"
) : (
<div className={styles.rowActions}>
<button
onClick={() =>
setPending({
kind: "state",
id: account.id,
state: account.state === "posting_banned" ? "active" : "posting_banned",
})
}
>
{account.state === "posting_banned" ? "Lift posting ban" : "Posting ban"}
</button>
<button
onClick={() =>
setPending({
kind: "state",
id: account.id,
state: account.state === "disabled" ? "active" : "disabled",
})
}
>
{account.state === "disabled" ? "Restore" : "Disable"}
</button>
{user.role === "admin" && (
<button
onClick={() =>
setPending({
kind: "role",
id: account.id,
role: account.role === "member" ? "moderator" : "member",
})
}
>
{account.role === "member" ? "Promote" : "Set member"}
</button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className={styles.adminSection}>
<h2>Recent actions</h2>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>Action</th>
<th>Target</th>
<th>Reason</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{actions.map((action) => (
<tr key={action.id}>
<td>{action.action}</td>
<td>
{action.targetType}:{action.targetId}
</td>
<td>{action.reason}</td>
<td>{new Date(action.created).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{pending && (
<ReasonDialog
action={pending}
onClose={() => setPending(null)}
onConfirm={async (reason) => {
if (pending.kind === "report") await client.resolveReport(pending.id, pending.status, reason);
else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason);
else await client.updateRole(pending.id, pending.role, reason);
setPending(null);
await load();
}}
/>
)}
</main>
);
}
function ReasonDialog({
action,
onClose,
onConfirm,
}: {
action: PendingAction;
onClose: () => void;
onConfirm: (reason: string) => Promise<void>;
}) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="moderation-reason-title">
<h2 id="moderation-reason-title">Confirm moderation action</h2>
<p>
This will apply <strong>{action.kind}</strong> to the selected record.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setSaving(true);
try {
await onConfirm(reason);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Action failed");
setSaving(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={saving} className="button button--danger">
{saving ? "Applying…" : "Apply action"}
</button>
</div>
</form>
</dialog>
</div>
);
}
@@ -1,16 +0,0 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopDetailRoutePage() {
return (
<Layout title="Workshop submission">
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopDetailRoute } = require("./WorkshopPage");
return <WorkshopDetailRoute />;
}}
</BrowserOnly>
</Layout>
);
}
@@ -1,668 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Link from "@docusaurus/Link";
import { useHistory, useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import {
IconAlertTriangle,
IconBrandGithub,
IconCopy,
IconDownload,
IconFlag,
IconSearch,
IconUpload,
} from "@tabler/icons-react";
import {
WORKSHOP_API_URL,
WorkshopClient,
type WorkshopSubmissionDetail,
type WorkshopSubmissionInput,
type WorkshopSubmissionSummary,
type WorkshopSubmissionType,
type WorkshopUser,
workshopExportFilename,
} from "@homarr/workshop";
import styles from "./workshop.module.css";
const useWorkshop = () => {
const { siteConfig } = useDocusaurusContext();
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL;
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
};
const saveContent = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = workshopExportFilename(submission.title, submission.type);
link.click();
URL.revokeObjectURL(url);
};
export function WorkshopApp() {
const client = useWorkshop();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [mine, setMine] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
useEffect(() => client.subscribeToAuth(setUser), [client]);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
await client.refreshAuth();
const result = await client.list({ page, type, sort, search, author: mine ? client.currentUser?.id : undefined });
setItems(result.items);
setTotalPages(result.totalPages);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Workshop is unavailable");
} finally {
setLoading(false);
}
}, [client, mine, page, search, sort, type]);
useEffect(() => {
const timeout = window.setTimeout(() => void load(), 250);
return () => window.clearTimeout(timeout);
}, [load]);
const signIn = async () => {
try {
await client.signInWithGitHub();
await load();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Sign-in failed");
}
};
return (
<main className={styles.page}>
<header className={styles.hero}>
<div>
<h1>Workshop</h1>
<p>Community widgets and CSS themes, ready to inspect and use in Homarr.</p>
</div>
<div className={styles.accountActions}>
{user ? (
<>
{(user.role === "moderator" || user.role === "admin") && (
<Link className="button button--secondary" to="/workshop/admin">
Moderate
</Link>
)}
<button className="button button--primary" onClick={() => setShowForm(true)}>
<IconUpload size={17} /> Share creation
</button>
<button className="button button--secondary" onClick={() => client.signOut()}>
Sign out
</button>
</>
) : (
<button className="button button--primary" onClick={() => void signIn()}>
<IconBrandGithub size={18} /> Sign in with GitHub
</button>
)}
</div>
</header>
<section className={styles.safety} aria-label="Community content warning">
<IconAlertTriangle aria-hidden size={20} />
<p>
Workshop content is community-provided. Inspect source before installing it and keep a current Homarr backup.
</p>
</section>
<div className={styles.toolbar}>
<label className={styles.search}>
<span className="sr-only">Search Workshop</span>
<IconSearch aria-hidden size={18} />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
placeholder="Search creations"
/>
</label>
<label>
Type
<select
value={type}
onChange={(event) => {
setType(event.target.value as typeof type);
setPage(1);
}}
>
<option value="all">All</option>
<option value="widget">Widgets</option>
<option value="css">CSS themes</option>
</select>
</label>
<label>
Sort
<select
value={sort}
onChange={(event) => {
setSort(event.target.value as typeof sort);
setPage(1);
}}
>
<option value="top">Top rated</option>
<option value="newest">Newest</option>
</select>
</label>
{user && (
<label className={styles.checkbox}>
<input
type="checkbox"
checked={mine}
onChange={(event) => {
setMine(event.target.checked);
setPage(1);
}}
/>{" "}
My submissions
</label>
)}
</div>
{error && (
<div className={styles.error} role="alert">
{error} <button onClick={() => void load()}>Try again</button>
</div>
)}
{loading ? (
<WorkshopSkeleton />
) : items.length ? (
<div className={styles.grid}>
{items.map((item) => (
<WorkshopCard client={client} item={item} key={item.id} />
))}
</div>
) : (
<section className={styles.empty}>
<h2>No creations found</h2>
<p>Adjust the filters, or share the first creation in this category.</p>
</section>
)}
{totalPages > 1 && (
<nav className={styles.pagination} aria-label="Workshop pages">
<button disabled={page === 1} onClick={() => setPage((value) => value - 1)}>
Previous
</button>
<span>
Page {page} of {totalPages}
</span>
<button disabled={page === totalPages} onClick={() => setPage((value) => value + 1)}>
Next
</button>
</nav>
)}
{showForm && (
<SubmissionForm
client={client}
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false);
await load();
}}
/>
)}
</main>
);
}
function WorkshopCard({ client, item }: { client: WorkshopClient; item: WorkshopSubmissionSummary }) {
const image = item.screenshots[0];
return (
<article className={styles.card}>
{image ? (
<img src={client.fileUrl(item.id, image, "480x320")} alt="" loading="lazy" />
) : (
<div className={styles.cardPlaceholder} aria-hidden>
{item.type === "widget" ? "{}" : "CSS"}
</div>
)}
<div className={styles.cardBody}>
<div className={styles.cardMeta}>
<span>{item.type === "widget" ? "Widget" : "CSS theme"}</span>
<span>
{item.score >= 0 ? "+" : ""}
{item.score}
</span>
</div>
<h2>
<Link to={`/workshop/${item.id}`}>{item.title}</Link>
</h2>
<p>{item.description || "No description provided."}</p>
<small>
By {item.authorName} · revision {item.revision}
</small>
</div>
</article>
);
}
export function WorkshopDetailRoute() {
const client = useWorkshop();
const location = useLocation();
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
const history = useHistory();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [submission, setSubmission] = useState<WorkshopSubmissionDetail | null>(null);
const [error, setError] = useState("");
const [reporting, setReporting] = useState(false);
const [editing, setEditing] = useState(false);
const [moderating, setModerating] = useState(false);
const load = useCallback(async () => {
try {
setSubmission(await client.get(id));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Submission not found");
}
}, [client, id]);
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void client.refreshAuth();
void load();
}, [client, load]);
if (error)
return (
<main className={styles.page}>
<div className={styles.error} role="alert">
{error}
</div>
<Link to="/workshop">Back to Workshop</Link>
</main>
);
if (!submission) return <WorkshopSkeleton />;
const requireAuth = async () => client.currentUser ?? client.signInWithGitHub();
return (
<main className={styles.page}>
<Link to="/workshop">← Back to Workshop</Link>
<article className={styles.detail}>
<header>
<span className={styles.type}>{submission.type === "widget" ? "Widget" : "CSS theme"}</span>
<h1>{submission.title}</h1>
<p>{submission.description}</p>
<small>
By {submission.authorName} · revision {submission.revision}
</small>
</header>
{submission.screenshots.length > 0 && (
<div className={styles.gallery}>
{submission.screenshots.map((image, index) => (
<img
key={image}
src={client.fileUrl(submission.id, image, "960x640")}
alt={`${submission.title} screenshot ${index + 1}`}
/>
))}
</div>
)}
<div className={styles.detailActions}>
<button className="button button--primary" onClick={() => saveContent(submission)}>
<IconDownload size={17} /> Download {submission.type === "widget" ? "JSON" : "CSS"}
</button>
<button
className="button button--secondary"
onClick={() => void navigator.clipboard.writeText(submission.content)}
>
<IconCopy size={17} /> Copy source
</button>
<button
className="button button--secondary"
onClick={async () => {
await requireAuth();
await client.vote(submission.id, 1);
await load();
}}
>
Upvote ({submission.upvotes})
</button>
<button className="button button--secondary" onClick={() => setReporting(true)}>
<IconFlag size={16} /> Report
</button>
{user?.id === submission.author && (
<>
<button className="button button--secondary" onClick={() => setEditing(true)}>
Edit
</button>
<button
className="button button--danger"
onClick={async () => {
if (window.confirm("Delete this submission permanently?")) {
await client.delete(submission.id);
history.push("/workshop");
}
}}
>
Delete
</button>
</>
)}
{(user?.role === "moderator" || user?.role === "admin") && user.id !== submission.author && (
<button className="button button--danger" onClick={() => setModerating(true)}>
Remove submission
</button>
)}
</div>
<section>
<h2>Source</h2>
<p className={styles.sourceHint}>
Review this content before using it. Workshop content is never executed on homarr.dev.
</p>
<pre className={styles.source}>
<code>{submission.content}</code>
</pre>
</section>
{submission.changelog && (
<section>
<h2>Latest changes</h2>
<p>{submission.changelog}</p>
</section>
)}
</article>
{reporting && <ReportForm client={client} submissionId={submission.id} onClose={() => setReporting(false)} />}
{editing && (
<SubmissionForm
client={client}
existing={submission}
onClose={() => setEditing(false)}
onSaved={async () => {
setEditing(false);
await load();
}}
/>
)}
{moderating && (
<ModerationRemovalForm
client={client}
submission={submission}
onClose={() => setModerating(false)}
onRemoved={() => history.push("/workshop/admin")}
/>
)}
</main>
);
}
function ModerationRemovalForm({
client,
submission,
onClose,
onRemoved,
}: {
client: WorkshopClient;
submission: WorkshopSubmissionDetail;
onClose: () => void;
onRemoved: () => void;
}) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="remove-submission-title">
<h2 id="remove-submission-title">Remove {submission.title}</h2>
<p>
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
moderation history.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setPending(true);
try {
await client.removeSubmission(submission.id, reason);
onRemoved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Removal failed");
setPending(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={pending} className="button button--danger">
{pending ? "Removing…" : "Remove permanently"}
</button>
</div>
</form>
</dialog>
</div>
);
}
function SubmissionForm({
client,
existing,
onClose,
onSaved,
}: {
client: WorkshopClient;
existing?: WorkshopSubmissionDetail;
onClose: () => void;
onSaved: () => void;
}) {
const [type, setType] = useState<WorkshopSubmissionType>(existing?.type ?? "widget");
const [title, setTitle] = useState(existing?.title ?? "");
const [description, setDescription] = useState(existing?.description ?? "");
const [content, setContent] = useState(existing?.content ?? "");
const [changelog, setChangelog] = useState(existing?.changelog ?? "");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
setError("");
const input: WorkshopSubmissionInput = { type, title, description, content, changelog };
try {
if (!client.currentUser) await client.signInWithGitHub();
if (existing) {
await client.update(existing.id, input, screenshots, removedScreenshots);
} else {
await client.create(input, screenshots);
}
await onSaved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not save submission");
} finally {
setPending(false);
}
};
return (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<dialog className={styles.dialog} open aria-labelledby="submission-form-title">
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
<form onSubmit={(event) => void submit(event)}>
<label>
Type
<select
disabled={Boolean(existing)}
value={type}
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
>
<option value="widget">Custom widget</option>
<option value="css">CSS theme</option>
</select>
</label>
<label>
Title
<input
required
minLength={3}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
Description
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
{type === "widget" ? "Widget JSON" : "CSS source"}
<textarea
className={styles.editor}
required
value={content}
onChange={(event) => setContent(event.target.value)}
/>
</label>
<label>
Changelog
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
</label>
<label>
Screenshots (PNG, JPG, or WebP, up to 5)
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => setScreenshots(Array.from(event.target.files ?? []).slice(0, 5))}
/>
</label>
{existing?.screenshots.map((screenshot) => (
<label className={styles.checkbox} key={screenshot}>
<input
type="checkbox"
checked={removedScreenshots.includes(screenshot)}
onChange={(event) =>
setRemovedScreenshots((current) =>
event.target.checked
? [...current, screenshot]
: current.filter((filename) => filename !== screenshot),
)
}
/>
Remove existing screenshot {screenshot}
</label>
))}
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={pending} className="button button--primary">
{pending ? "Saving…" : existing ? "Save update" : "Publish creation"}
</button>
</div>
</form>
</dialog>
</div>
);
}
function ReportForm({
client,
submissionId,
onClose,
}: {
client: WorkshopClient;
submissionId: string;
onClose: () => void;
}) {
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
const [explanation, setExplanation] = useState("");
const [error, setError] = useState("");
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="report-title">
<h2 id="report-title">Report submission</h2>
<form
onSubmit={async (event) => {
event.preventDefault();
try {
if (!client.currentUser) await client.signInWithGitHub();
await client.report(submissionId, category, explanation);
onClose();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not send report");
}
}}
>
<label>
Reason
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<label>
Details
<textarea
required
minLength={3}
maxLength={1000}
value={explanation}
onChange={(event) => setExplanation(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button className="button button--primary">Submit report</button>
</div>
</form>
</dialog>
</div>
);
}
function WorkshopSkeleton() {
return (
<div className={styles.grid} aria-label="Loading Workshop">
{Array.from({ length: 6 }, (_, index) => (
<div className={styles.skeleton} key={index} />
))}
</div>
);
}
@@ -1,299 +0,0 @@
.page {
max-width: 1240px;
min-height: 70vh;
margin: 0 auto;
padding: 2.5rem 1.25rem 5rem;
}
.hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
margin-bottom: 1.5rem;
}
.hero h1,
.detail h1 {
margin: 0;
font-size: 2.25rem;
letter-spacing: -0.025em;
}
.hero p {
max-width: 65ch;
margin: 0.5rem 0 0;
color: var(--ifm-color-emphasis-700);
}
.accountActions,
.detailActions,
.formActions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.accountActions :global(.button),
.detailActions :global(.button) {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.safety {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.85rem 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--ifm-color-warning-dark);
border-radius: 10px;
background: color-mix(in srgb, var(--ifm-color-warning) 10%, transparent);
}
.safety p {
margin: 0;
}
.toolbar {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem;
margin-bottom: 1.5rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 10px;
}
.toolbar label,
.dialog label {
display: grid;
gap: 0.3rem;
font-size: 0.8rem;
font-weight: 600;
}
.toolbar input,
.toolbar select,
.dialog input,
.dialog select,
.dialog textarea {
min-height: 44px;
padding: 0.55rem 0.7rem;
border: 1px solid var(--ifm-color-emphasis-400);
border-radius: 7px;
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
font: inherit;
}
.search {
position: relative;
flex: 1 1 280px;
}
.search svg {
position: absolute;
left: 0.75rem;
bottom: 0.8rem;
color: var(--ifm-color-emphasis-600);
}
.search input {
width: 100%;
padding-left: 2.25rem;
}
.checkbox {
display: flex !important;
grid-auto-flow: column;
align-items: center;
min-height: 44px;
}
.checkbox input {
min-height: auto;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 1rem;
}
.card {
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
background: var(--ifm-background-surface-color);
}
.card img,
.cardPlaceholder {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
}
.cardPlaceholder {
display: grid;
place-items: center;
background: var(--ifm-color-emphasis-100);
color: var(--ifm-color-emphasis-600);
font: 700 1.5rem var(--ifm-font-family-monospace);
}
.cardBody {
padding: 1rem;
}
.cardBody h2 {
margin: 0.4rem 0;
font-size: 1.08rem;
}
.cardBody p {
min-height: 2.7em;
margin: 0 0 0.65rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.9rem;
}
.cardMeta {
display: flex;
justify-content: space-between;
color: var(--ifm-color-emphasis-700);
font-size: 0.75rem;
font-weight: 700;
}
.error {
padding: 0.85rem 1rem;
margin: 1rem 0;
border-radius: 8px;
color: var(--ifm-color-danger-contrast-foreground);
background: var(--ifm-color-danger-contrast-background);
}
.empty {
padding: 4rem 1rem;
text-align: center;
border: 1px dashed var(--ifm-color-emphasis-400);
border-radius: 12px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.pagination button {
min-height: 44px;
padding: 0.5rem 0.8rem;
}
.skeleton {
min-height: 310px;
border-radius: 12px;
background: var(--ifm-color-emphasis-200);
animation: pulse 1.5s ease-in-out infinite;
}
.detail {
margin-top: 1.5rem;
}
.detail header {
max-width: 75ch;
}
.type {
display: inline-block;
margin-bottom: 0.5rem;
color: var(--ifm-color-primary);
font-weight: 700;
}
.gallery {
display: flex;
gap: 1rem;
overflow-x: auto;
margin: 1.5rem 0;
scroll-snap-type: x mandatory;
}
.gallery img {
width: min(720px, 85vw);
max-height: 480px;
object-fit: contain;
border-radius: 10px;
background: var(--ifm-color-emphasis-100);
scroll-snap-align: start;
}
.detailActions {
margin: 1.5rem 0 2rem;
}
.sourceHint {
color: var(--ifm-color-emphasis-700);
}
.source {
max-height: 560px;
overflow: auto;
padding: 1rem;
border-radius: 8px;
font-size: 0.78rem;
}
.overlay {
position: fixed;
z-index: 500;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(0 0 0 / 0.7);
}
.dialog {
width: min(680px, 100%);
max-height: 90vh;
overflow-y: auto;
padding: 1.25rem;
border-radius: 12px;
background: var(--ifm-background-color);
}
.dialog form {
display: grid;
gap: 1rem;
}
.dialog textarea {
min-height: 88px;
resize: vertical;
}
.dialog .editor {
min-height: 240px;
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
}
.formActions {
justify-content: flex-end;
}
@keyframes pulse {
50% {
opacity: 0.55;
}
}
@media (max-width: 700px) {
.hero {
align-items: stretch;
flex-direction: column;
}
.accountActions > * {
flex: 1;
justify-content: center;
}
.toolbar > label {
flex: 1 1 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.adminSection {
margin: 2rem 0;
}
.tableWrap {
overflow-x: auto;
}
.tableWrap table {
width: 100%;
border-collapse: collapse;
}
.tableWrap th,
.tableWrap td {
padding: 0.7rem;
border-bottom: 1px solid var(--ifm-color-emphasis-300);
text-align: left;
vertical-align: top;
}
.rowActions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.rowActions button {
min-height: 36px;
}
@@ -1,16 +0,0 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopAdminPage() {
return (
<Layout title="Workshop moderation" noFooter>
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopAdmin } = require("@site/src/components/workshop/WorkshopAdmin");
return <WorkshopAdmin />;
}}
</BrowserOnly>
</Layout>
);
}
-16
View File
@@ -1,16 +0,0 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopPage() {
return (
<Layout title="Workshop" description="Community widgets and CSS themes for Homarr">
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopPage");
return <WorkshopApp />;
}}
</BrowserOnly>
</Layout>
);
}
-1
View File
@@ -50,7 +50,6 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/websocket": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@mantine/colors-generator": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dropzone": "catalog:",
@@ -2,6 +2,7 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -33,6 +34,7 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -90,6 +92,13 @@ export function TRPCReactProvider(props: PropsWithChildren) {
},
});
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
for (const { definition } of Object.values(widgetImports)) {
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
if (def.refetchInterval === undefined) continue;
const key = def.queryKey ?? [["widget", def.kind]];
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
client.setQueryDefaults(key, { refetchInterval: interval });
}
return client;
});
@@ -11,9 +11,6 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
@@ -48,11 +45,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="space-between">
<Group>
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
<PublishToWorkshopButton type="css" defaultTitle={board.name} getContent={() => form.values.customCss} />
</Group>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) {
if (
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
notFound();
}
const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("app-create")) {
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
notFound();
}
@@ -1,5 +1,5 @@
import { Fragment } from "react";
import { redirect } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -33,6 +33,9 @@ export default async function AppsPage(props: AppsPageProps) {
if (!session) {
redirect("/auth/login");
}
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
const searchParams = searchParamsSchema.parse(await props.searchParams);
@@ -6,7 +6,6 @@ import {
IconCopy,
IconDots,
IconDownload,
IconShare3,
IconToggleLeft,
IconToggleRight,
IconTrash,
@@ -20,7 +19,6 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
const iconProps = { size: 16, stroke: 1.5 };
@@ -32,7 +30,6 @@ interface WidgetRef {
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const workshopT = useScopedI18n("workshop");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
@@ -144,16 +141,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<PublishToWorkshopButton
type="widget"
defaultTitle={widget.name}
getContent={async () => JSON.stringify(await utils.customWidget.export.fetch({ id: widget.id }), null, 2)}
renderTrigger={(open) => (
<Menu.Item onClick={open} leftSection={<IconShare3 {...iconProps} />}>
{workshopT("action.share")}
</Menu.Item>
)}
/>
<Menu.Divider />
<Menu.Item
color="red"
@@ -9,7 +9,6 @@ import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { InstallWidgetFromWorkshopButton } from "~/components/workshop/workshop-actions";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { CustomWidgetList } from "./_custom-widget-list";
@@ -29,7 +28,6 @@ export default async function CustomWidgetsPage() {
title={t("page.list.title")}
primaryAction={
<Group gap="xs">
<InstallWidgetFromWorkshopButton />
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
@@ -18,7 +18,6 @@ import {
IconHomeFilled,
IconLayoutDashboardFilled,
IconMailForward,
IconBuildingStore,
IconPhotoFilled,
IconPointerFilled,
IconSearch,
@@ -62,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session,
hidden: !session?.user.permissions.includes("board-modify-all"),
iconProps: {
strokeWidth: 2.5,
},
@@ -81,12 +80,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.customWidgets"),
hidden: !session,
},
{
icon: IconBuildingStore,
href: "/manage/workshop",
label: t("items.workshop"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
@@ -193,6 +193,10 @@ export function DockerTable({ initialData }: DockerTableProps) {
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
// MRT's built-in full-screen mode does not stretch the table to fill the
// viewport, so with few containers it renders as a thin strip (#6230). Disable
// it, matching the other management tables (users, invites).
enableFullScreenToggle: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }),
@@ -31,6 +31,7 @@ import {
userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema,
userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema,
userPingIconsEnabledSchema,
} from "@homarr/validation/user";
@@ -51,6 +52,7 @@ const userGeneralSettingsSchema = z.object({
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
});
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -78,6 +80,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
});
export const UserGeneralSettingsForm = ({
@@ -95,6 +98,7 @@ export const UserGeneralSettingsForm = ({
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues);
@@ -114,6 +118,7 @@ export const UserGeneralSettingsForm = ({
changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
];
const isPending = mutations.some((m) => m.isPending);
@@ -169,6 +174,14 @@ export const UserGeneralSettingsForm = ({
action: () =>
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
},
{
when: changed("enableRightClickOnWidgets"),
action: () =>
changeEnableRightClickOnWidgetsMutation.mutateAsync({
id: user.id,
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
}),
},
];
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -305,6 +318,11 @@ export const UserGeneralSettingsForm = ({
label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
<Switch
label={t("user.field.enableRightClickOnWidgets.label")}
description={t("user.field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
@@ -1,18 +0,0 @@
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { WorkshopBrowser } from "~/components/workshop/workshop-browser";
export default async function WorkshopPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const t = await getScopedI18n("workshop");
return (
<ManagePageLayout title={t("title")} size="xl">
<WorkshopBrowser />
</ManagePageLayout>
);
}
@@ -1,19 +1,25 @@
import { notFound } from "next/navigation";
import { ImageProxy } from "@homarr/image-proxy";
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
const { id } = await props.params;
const imageProxy = new ImageProxy();
const image = await imageProxy.forwardImageAsync(id);
if (!image) {
notFound();
const result = await imageProxy.forwardImageAsync(id);
if ("error" in result) {
const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404;
return new Response(null, {
status,
headers: {
"X-Homarr-Image-Proxy-Error": result.error,
},
});
}
return new Response(image, {
return new Response(result.image, {
headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
},
});
};
@@ -1,9 +1,17 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import { useCallback, useMemo } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -11,10 +19,12 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -38,6 +48,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const settings = useSettings();
const isRightClickEnabled = settings.enableRightClickOnWidgets;
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
@@ -158,6 +169,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
@@ -232,11 +244,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap">
<Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
</Text>
<WidgetQueryStatus
queryClient={queryClient}
queryKey={widgetQueryKey}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
@@ -273,34 +288,65 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
};
const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
interface WidgetQueryStatusProps {
queryClient: QueryClient;
queryKey: QueryKey;
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
t: TranslationFunction;
}
if (isFetching) return <Loader size={12} />;
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({ queryKey });
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
const ageLabel = useTimeAgo(new Date(latest));
const timestamps = queryClient
.getQueryCache()
.findAll({ queryKey })
.map((q) => q.state.dataUpdatedAt)
.filter(Boolean);
if (timestamps.length === 0) return null;
if (isFetching) {
return (
<Group gap={4} wrap="nowrap">
<Loader size={12} />
<Text size="xs" c="dimmed">
{t("item.menu.status.loading")}
</Text>
</Group>
);
}
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
if (queries.length === 0) {
return (
<Text size="xs" c="dimmed">
{t("item.menu.status.idle")}
</Text>
);
}
const hasError = queries.some((q) => q.state.status === "error");
if (hasError) {
const errorQuery = queries.find((q) => q.state.status === "error");
const errorMessage =
errorQuery?.state.error instanceof Error
? errorQuery.state.error.message
: String(errorQuery?.state.error ?? "Unknown error");
return (
<Tooltip label={errorMessage} multiline position="left" w={250}>
<Group gap={4} wrap="nowrap">
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red.6">
{t("item.menu.status.error")} · {ageLabel}
</Text>
</Group>
</Tooltip>
);
}
return (
<Group gap={4} wrap="nowrap">
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("item.menu.status.success")} · {ageLabel}
</Text>
</Group>
);
};
@@ -1,72 +0,0 @@
"use client";
import { Button, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { validateWorkshopContent } from "@homarr/workshop";
import { WorkshopBrowser } from "./workshop-browser";
export function InstallWidgetFromWorkshopButton() {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
const utils = clientApi.useUtils();
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("title"), message: t("action.install") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
close();
},
onError: (error) => showErrorNotification({ title: t("title"), message: error.message }),
});
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.install")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%" fullScreen={false}>
<WorkshopBrowser
initialType="widget"
lockedType="widget"
useLabel={t("action.install")}
onUse={async (submission) => {
const parsed = customWidgetImportSchema.parse(JSON.parse(submission.content));
await mutation.mutateAsync(parsed);
}}
/>
</Modal>
</>
);
}
export function UseCssFromWorkshopButton({ onUse }: { onUse: (css: string) => void }) {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.useCss")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%">
<WorkshopBrowser
initialType="css"
lockedType="css"
useLabel={t("action.useCss")}
onUse={(submission) => {
const validation = validateWorkshopContent("css", submission.content);
if (!validation.success) throw new Error(validation.error);
onUse(submission.content);
close();
}}
/>
</Modal>
</>
);
}
@@ -1,339 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Card,
Code,
Group,
Image,
Modal,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconBrandGithub, IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import type { WorkshopSubmissionDetail, WorkshopSubmissionType } from "@homarr/workshop";
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
const apiUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
export interface WorkshopBrowserProps {
initialType?: WorkshopSubmissionType | "all";
lockedType?: WorkshopSubmissionType;
onUse?: (submission: WorkshopSubmissionDetail) => Promise<void> | void;
useLabel?: string;
}
export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [page, setPage] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">(initialType);
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [usePending, setUsePending] = useState(false);
const [useError, setUseError] = useState("");
const query = useQuery({
queryKey: ["workshop", "list", page, type, sort, debouncedSearch],
queryFn: () => client.list({ page, type, sort, search: debouncedSearch, perPage: 12 }),
});
const openDetail = async (id: string) => {
setDetailLoading(true);
try {
setSelected(await client.get(id));
} finally {
setDetailLoading(false);
}
};
const download = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = workshopExportFilename(submission.title, submission.type);
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Stack gap="lg">
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("safety.title")}>
{t("safety.description")}
</Alert>
<Group align="end">
<TextInput
label={t("search.label")}
placeholder={t("search.placeholder")}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
style={{ flex: 1 }}
/>
<Select
label={t("filter.type")}
value={type}
disabled={Boolean(lockedType)}
onChange={(value) => {
setType((value ?? "all") as typeof type);
setPage(1);
}}
data={[
{ value: "all", label: t("filter.all") },
{ value: "widget", label: t("filter.widgets") },
{ value: "css", label: t("filter.css") },
]}
/>
<Select
label={t("filter.sort")}
value={sort}
onChange={(value) => {
setSort((value ?? "top") as typeof sort);
setPage(1);
}}
data={[
{ value: "top", label: t("filter.top") },
{ value: "newest", label: t("filter.newest") },
]}
/>
</Group>
{query.error && (
<Alert color="red" title={t("error.loadTitle")}>
{query.error.message}
<Button variant="subtle" onClick={() => void query.refetch()}>
{t("action.retry")}
</Button>
</Alert>
)}
{query.isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<Skeleton h={280} />
<Skeleton h={280} />
<Skeleton h={280} />
</SimpleGrid>
) : query.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{query.data.items.map((item) => (
<Card key={item.id} withBorder padding="md">
{item.screenshots[0] ? (
<Card.Section>
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
</Card.Section>
) : (
<Card.Section>
<Box
h={170}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Text ff="monospace" fw={700}>
{item.type === "widget" ? "{}" : "CSS"}
</Text>
</Box>
</Card.Section>
)}
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Badge variant="light">{item.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{item.score >= 0 ? "+" : ""}
{item.score}
</Text>
</Group>
<Text fw={600} lineClamp={1}>
{item.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("empty.description")}
</Text>
<Text size="xs" c="dimmed">
{t("by", { name: item.authorName, revision: item.revision })}
</Text>
<Button variant="light" onClick={() => void openDetail(item.id)}>
{t("action.inspect")}
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Stack align="center" py="xl">
<Text fw={600}>{t("empty.title")}</Text>
<Text c="dimmed">{t("empty.description")}</Text>
</Stack>
)}
{(query.data?.totalPages ?? 0) > 1 && (
<Pagination total={query.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
)}
<Modal
opened={Boolean(selected) || detailLoading}
onClose={() => setSelected(null)}
title={selected?.title ?? t("loading")}
size="xl"
>
{detailLoading || !selected ? (
<Skeleton h={400} />
) : (
<Stack>
<Group>
<Badge>{selected.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{t("by", { name: selected.authorName, revision: selected.revision })}
</Text>
</Group>
<Text>{selected.description}</Text>
<Alert icon={<IconAlertTriangle size={18} />} color="yellow">
{t("safety.inspect")}
</Alert>
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{selected.content}
</Code>
<Group justify="space-between">
<Group>
<Button variant="default" leftSection={<IconDownload size={16} />} onClick={() => download(selected)}>
{t("action.download")}
</Button>
<Button
component="a"
href={`${WORKSHOP_WEB_URL}/${selected.id}`}
target="_blank"
variant="subtle"
leftSection={<IconExternalLink size={16} />}
>
{t("action.website")}
</Button>
</Group>
{onUse && (
<Button
loading={usePending}
onClick={async () => {
setUsePending(true);
setUseError("");
try {
if (lockedType && selected.type !== lockedType) throw new Error(t("error.wrongType"));
await onUse(selected);
setSelected(null);
} catch (caught) {
setUseError(caught instanceof Error ? caught.message : t("error.install"));
} finally {
setUsePending(false);
}
}}
>
{useLabel ?? t("action.use")}
</Button>
)}
</Group>
{useError && <Alert color="red">{useError}</Alert>}
</Stack>
)}
</Modal>
</Stack>
);
}
export function PublishToWorkshopButton({
type,
getContent,
defaultTitle,
compact = false,
renderTrigger,
}: {
type: WorkshopSubmissionType;
getContent: () => Promise<string> | string;
defaultTitle: string;
compact?: boolean;
renderTrigger?: (open: () => void) => ReactNode;
}) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [opened, { open, close }] = useDisclosure(false);
const [title, setTitle] = useState(defaultTitle);
const [description, setDescription] = useState("");
const [changelog, setChangelog] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => setTitle(defaultTitle), [defaultTitle]);
const publish = async () => {
setPending(true);
setError("");
try {
if (!client.currentUser) await client.signInWithGitHub();
await client.create({ type, title, description, changelog, content: await getContent() });
close();
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("error.publish"));
} finally {
setPending(false);
}
};
return (
<>
{renderTrigger ? (
renderTrigger(open)
) : (
<Button
size={compact ? "xs" : undefined}
variant="default"
leftSection={<IconBrandGithub size={16} />}
onClick={open}
>
{t("action.share")}
</Button>
)}
<Modal opened={opened} onClose={close} title={t("publish.title")}>
<Stack>
<TextInput
required
label={t("publish.name")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
/>
<Textarea
label={t("publish.description")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
<Textarea
label={t("publish.changelog")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
/>
{error && <Alert color="red">{error}</Alert>}
<Group justify="end">
<Button variant="default" onClick={close}>
{t("action.cancel")}
</Button>
<Button loading={pending} disabled={title.trim().length < 3} onClick={() => void publish()}>
{t("action.publish")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
-5
View File
@@ -1,5 +0,0 @@
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
PB_SUPERUSER_EMAIL=
PB_SUPERUSER_PASSWORD=
PB_EXPOSE_PORT=8090
-4
View File
@@ -1,4 +0,0 @@
.env
.env.*
!.env.example
pb_data/
-31
View File
@@ -1,31 +0,0 @@
FROM alpine:3.22 AS download
ARG TARGETARCH
ARG POCKETBASE_VERSION=0.39.6
RUN apk add --no-cache ca-certificates curl unzip
RUN case "${TARGETARCH}" in \
amd64) PB_ARCH=amd64 ;; \
arm64) PB_ARCH=arm64 ;; \
arm) PB_ARCH=armv7 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22
RUN apk add --no-cache ca-certificates su-exec tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=download /out/pocketbase /usr/local/bin/pocketbase
COPY entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_hooks /pb_migrations && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations
USER root
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["workshop-entrypoint"]
-45
View File
@@ -1,45 +0,0 @@
# Homarr Workshop backend
PocketBase provides the central Workshop API at `store.homarr.dev`. It is not a per-instance Homarr service. Local deployment is for development and integration testing.
## Local startup
1. Copy `.env.example` to `.env` and create a GitHub OAuth app.
2. Set its callback URL to `http://localhost:8090/api/oauth2-redirect`.
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
4. Run `docker compose up --build` from this directory.
The API is at `http://localhost:8090`; the emergency PocketBase dashboard is at `http://localhost:8090/_/`. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
The image downloads the official PocketBase `0.39.6` release and verifies it against that release's `checksums.txt`. Migrations and hooks are mounted read-only.
## Roles and account states
- `member`: publish, update/delete owned submissions, vote, and report.
- `moderator`: report queue, submission removal, posting bans, and account disable/restore.
- `admin`: moderator actions plus role changes.
- `posting_banned`: cannot create or update submissions; can browse, vote, and report.
- `disabled`: cannot authenticate or write.
Bootstrap the first admin through the PocketBase dashboard by changing a GitHub-authenticated user's role. All later staff actions use the restricted Workshop back office and are recorded in `moderation_actions`.
## Backup and restore
PocketBase stores records and uploads in `/pb_data`. Stop writes or stop the container, then archive the named volume:
```sh
docker compose stop workshop
docker run --rm -v store_workshop_data:/data -v "$PWD":/backup alpine tar czf /backup/workshop-backup.tgz -C /data .
docker compose start workshop
```
Restore only into the same PocketBase version after preserving the current volume. Extract the archive into an empty `workshop_data` volume, start the service, and verify `/api/health`, collection migrations, a listing request, and an authenticated write.
## Production checklist
- Terminate TLS at the reverse proxy and forward the real client IP.
- Keep superuser credentials outside Compose and rotate them after bootstrap.
- Restrict the dashboard path to operators; GitHub users never need it.
- Review the versioned default rate limits for OAuth, submissions, votes, reports, moderation, and general API traffic against production load before launch.
- Back up `/pb_data` before every PocketBase or migration upgrade and test restore regularly.
- Monitor health, authentication failures, 429/5xx responses, moderation actions, disk use, and backup age.
-24
View File
@@ -1,24 +0,0 @@
services:
workshop:
build:
context: .
args:
POCKETBASE_VERSION: 0.39.6
image: homarr/workshop-pocketbase:0.39.6
restart: unless-stopped
environment:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-}
PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-}
ports:
- "${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- workshop_data:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
volumes:
workshop_data:
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
set -eu
chown -R pocketbase:pocketbase /pb_data
if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
su-exec pocketbase pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
fi
exec su-exec pocketbase pocketbase serve \
--http=0.0.0.0:8090 \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations
-102
View File
@@ -1,102 +0,0 @@
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CSS_SCHEMA = "homarr-custom-css-v1";
const CSS_LIMIT = 16384;
const WIDGET_AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
const WIDGET_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
const WIDGET_DISPLAY_TYPES = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
];
function fail(ErrorType, _code, message) {
throw new ErrorType(message);
}
function requireWritableAccount(app, auth, posting) {
if (!auth) fail(UnauthorizedError, "authentication_required", "Authentication is required");
const account = app.findRecordById("users", auth.id);
if (account.getString("state") === "disabled") fail(ForbiddenError, "account_disabled", "Account is disabled");
if (posting && account.getString("state") === "posting_banned")
fail(ForbiddenError, "posting_banned", "Account cannot publish submissions");
return account;
}
function validateSubmission(record) {
const type = record.getString("type");
const content = record.getString("content");
if (type === "css") {
if (!content.trim()) fail(BadRequestError, "invalid_submission", "CSS cannot be empty");
if (content.length > CSS_LIMIT) fail(BadRequestError, "invalid_submission", "CSS is too large");
record.set("schemaVersion", CSS_SCHEMA);
return;
}
if (type !== "widget") fail(BadRequestError, "invalid_submission", "Unsupported submission type");
let widget;
try {
widget = JSON.parse(content);
} catch {
fail(BadRequestError, "invalid_submission", "Widget content is not valid JSON");
}
if (
!widget ||
widget.$schema !== WIDGET_SCHEMA ||
typeof widget.name !== "string" ||
widget.name.length < 1 ||
widget.name.length > 128 ||
typeof widget.url !== "string" ||
widget.url.length < 1 ||
!WIDGET_AUTH_TYPES.includes(widget.authType) ||
!WIDGET_METHODS.includes(widget.method) ||
!WIDGET_DISPLAY_TYPES.includes(widget.displayType) ||
typeof widget.displayConfig !== "object" ||
Array.isArray(widget.displayConfig) ||
Object.prototype.hasOwnProperty.call(widget, "secrets")
) {
fail(BadRequestError, "invalid_submission", "Widget does not match homarr-custom-widget-v2");
}
if (!widget.displayConfig || widget.displayConfig.type !== widget.displayType) {
fail(BadRequestError, "invalid_submission", "Widget displayConfig.type must match displayType");
}
record.set("schemaVersion", WIDGET_SCHEMA);
}
function snapshot(record) {
return JSON.stringify(record.publicExport());
}
function requireStaff(app, auth) {
const account = requireWritableAccount(app, auth, false);
const role = account.getString("role");
if (role !== "moderator" && role !== "admin") fail(ForbiddenError, "forbidden", "Staff access is required");
return role;
}
function body(e) {
const data = new DynamicModel({ reason: "", state: "", role: "", status: "" });
e.bindBody(data);
if (!data.reason || data.reason.trim().length < 3)
fail(BadRequestError, "invalid_submission", "A reason is required");
return data;
}
function audit(app, actor, action, targetType, targetId, reason, targetSnapshot) {
const collection = app.findCollectionByNameOrId("moderation_actions");
const record = new Record(collection);
record.set("actor", actor.id);
record.set("action", action);
record.set("targetType", targetType);
record.set("targetId", targetId);
record.set("reason", reason.trim());
record.set("snapshot", targetSnapshot || "");
app.save(record);
}
module.exports = { audit, body, fail, requireStaff, requireWritableAccount, snapshot, validateSubmission };
-168
View File
@@ -1,168 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordAuthWithOAuth2Request((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (e.record && e.record.getString("state") === "disabled")
utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
if (e.isNewRecord) {
e.createData.displayName = e.oAuth2User.name || e.oAuth2User.username || "Community member";
e.createData.avatarUrl = e.oAuth2User.avatarUrl || "";
e.createData.role = "member";
e.createData.state = "active";
}
e.next();
}, "users");
onRecordAuthRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (e.record.getString("state") === "disabled") utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
e.next();
}, "users");
onRecordUpdateRequest((e) => {
const original = e.app.findRecordById("users", e.record.id);
e.record.set("role", original.get("role"));
e.record.set("state", original.get("state"));
e.record.set("moderationReason", original.get("moderationReason"));
e.next();
}, "users");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const account = utils.requireWritableAccount(e.app, e.auth, true);
utils.validateSubmission(e.record);
e.record.set("author", e.auth.id);
e.record.set("authorName", account.getString("displayName"));
e.record.set("revision", 1);
e.next();
}, "submissions");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, true);
const original = e.app.findRecordById("submissions", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("authorName", original.get("authorName"));
e.record.set("type", original.get("type"));
e.record.set("schemaVersion", original.get("schemaVersion"));
e.record.set(
"revision",
original.getInt("revision") + (e.record.getString("content") !== original.getString("content") ? 1 : 0),
);
utils.validateSubmission(e.record);
e.next();
}, "submissions");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", e.auth.id);
e.next();
}, "votes");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const original = e.app.findRecordById("votes", e.record.id);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", original.get("user"));
e.record.set("submission", original.get("submission"));
e.next();
}, "votes");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
e.record.set("reporter", e.auth.id);
e.record.set("status", "open");
e.next();
}, "reports");
routerAdd(
"POST",
"/api/workshop/moderation/submissions/{id}/remove",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("submissions", id);
utils.audit(tx, e.auth, "remove_submission", "submission", id, data.reason, utils.snapshot(target));
tx.delete(target);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/state",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const actorRole = utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["active", "posting_banned", "disabled"].includes(data.state))
throw new BadRequestError("Invalid account state");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot moderate your own account");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
if (target.getString("role") === "admin" && actorRole !== "admin") throw new ForbiddenError("forbidden");
const previous = utils.snapshot(target);
target.set("state", data.state);
target.set("moderationReason", data.reason);
tx.save(target);
utils.audit(tx, e.auth, "set_account_state", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/role",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (utils.requireStaff(e.app, e.auth) !== "admin") throw new ForbiddenError("forbidden");
const data = utils.body(e);
if (!["member", "moderator", "admin"].includes(data.role)) throw new BadRequestError("Invalid role");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot change your own role");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
const previous = utils.snapshot(target);
target.set("role", data.role);
tx.save(target);
utils.audit(tx, e.auth, "set_role", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/reports/{id}/resolve",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["resolved", "dismissed"].includes(data.status)) throw new BadRequestError("Invalid report status");
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("reports", id);
const previous = utils.snapshot(target);
target.set("status", data.status);
tx.save(target);
utils.audit(tx, e.auth, "resolve_report", "report", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
@@ -1,248 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
let users;
try {
users = app.findCollectionByNameOrId("users");
} catch {
users = new Collection({
type: "auth",
name: "users",
listRule: "@request.auth.role = 'moderator' || @request.auth.role = 'admin'",
viewRule: "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'",
updateRule: "id = @request.auth.id && @request.auth.state != 'disabled'",
deleteRule: null,
passwordAuth: { enabled: false },
});
}
// PocketBase creates a default users auth collection on first boot, so
// Workshop fields must be added to the existing collection explicitly.
users.fields.add(
new TextField({ name: "displayName", required: true, min: 1, max: 100 }),
new URLField({ name: "avatarUrl" }),
new SelectField({ name: "role", required: true, maxSelect: 1, values: ["member", "moderator", "admin"] }),
new SelectField({
name: "state",
required: true,
maxSelect: 1,
values: ["active", "posting_banned", "disabled"],
}),
new TextField({ name: "moderationReason", max: 1000 }),
);
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
users.oauth2.enabled = Boolean(githubClientId && githubClientSecret);
users.oauth2.providers =
githubClientId && githubClientSecret
? [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }]
: [];
app.save(users);
const staffRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
const writableRule = "@request.auth.id != '' && @request.auth.state != 'disabled'";
const authorRule = "author = @request.auth.id && @request.auth.state = 'active'";
const submissions = new Collection({
type: "base",
name: "submissions",
// Full source is intentionally available only through getOne; discovery
// must use workshop_listings so list payloads never contain content.
listRule: null,
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["widget", "css"] },
{ type: "text", name: "title", required: true, min: 3, max: 100 },
{ type: "text", name: "description", max: 2000 },
{ type: "text", name: "schemaVersion", required: true, max: 100 },
{ type: "text", name: "content", required: true, max: 1000000 },
{
type: "file",
name: "screenshots",
maxSelect: 5,
maxSize: 5242880,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
thumbs: ["480x320", "960x640"],
},
{
type: "relation",
name: "author",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "text", name: "authorName", required: true, max: 100 },
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
{ type: "text", name: "changelog", max: 2000 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(submissions);
submissions.createRule = "@request.auth.id != ''";
submissions.updateRule = authorRule;
submissions.deleteRule = authorRule;
submissions.addIndex("idx_submissions_author", false, "author", "");
submissions.addIndex("idx_submissions_type_created", false, "type, created", "");
app.save(submissions);
const votes = new Collection({
type: "base",
name: "votes",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "number", name: "value", required: true, onlyInt: true, min: -1, max: 1 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(votes);
votes.listRule = "user = @request.auth.id";
votes.viewRule = "user = @request.auth.id";
votes.createRule = writableRule;
votes.updateRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.deleteRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.addIndex("idx_votes_user_submission", true, "user, submission", "");
votes.addIndex("idx_votes_submission", false, "submission", "");
app.save(votes);
const reports = new Collection({
type: "base",
name: "reports",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{
type: "relation",
name: "reporter",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{
type: "select",
name: "category",
required: true,
maxSelect: 1,
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
},
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "resolved", "dismissed"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(reports);
reports.listRule = staffRule;
reports.viewRule = staffRule;
reports.createRule = writableRule;
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
reports.addIndex("idx_reports_status_created", false, "status, created", "");
app.save(reports);
const moderationActions = new Collection({
type: "base",
name: "moderation_actions",
listRule: staffRule,
viewRule: staffRule,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
{
type: "select",
name: "action",
required: true,
maxSelect: 1,
values: ["remove_submission", "set_account_state", "set_role", "resolve_report"],
},
{ type: "text", name: "targetType", required: true, max: 30 },
{ type: "text", name: "targetId", required: true, max: 30 },
{ type: "text", name: "reason", required: true, min: 3, max: 1000 },
{ type: "text", name: "snapshot", max: 100000 },
{ type: "autodate", name: "created", onCreate: true },
],
});
app.save(moderationActions);
moderationActions.addIndex("idx_moderation_actions_created", false, "created", "");
app.save(moderationActions);
const listings = new Collection({
type: "view",
name: "workshop_listings",
listRule: "",
viewRule: "",
viewQuery: `
SELECT s.id, s.type, s.title, s.description, s.schemaVersion, s.screenshots,
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
COALESCE(SUM(v.value), 0) AS score
FROM submissions s
LEFT JOIN votes v ON v.submission = s.id
GROUP BY s.id
`,
});
app.save(listings);
const settings = app.settings();
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
{ label: "*:auth", audience: "", duration: 60, maxRequests: 10 },
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
{ label: "/api/workshop/moderation/", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "/api/", audience: "", duration: 10, maxRequests: 300 },
];
settings.logs.maxDays = 30;
settings.logs.logAuthId = true;
settings.logs.logIP = true;
app.save(settings);
},
(app) => {
// PocketBase creates the users auth collection before this migration. Keep
// it intact on rollback so reverting Workshop can never delete accounts.
for (const name of ["workshop_listings", "moderation_actions", "reports", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
// Collection was not created or was already removed.
}
}
const settings = app.settings();
settings.rateLimits.enabled = false;
settings.rateLimits.rules = [];
app.save(settings);
},
);
-26
View File
@@ -1,26 +0,0 @@
#!/bin/sh
set -eu
project="homarr-workshop-integration"
port="${WORKSHOP_TEST_PORT:-18091}"
email="integration@homarr.test"
password="WorkshopIntegrationPassword123!"
cleanup() {
PB_EXPOSE_PORT="$port" docker compose --file apps/store/docker-compose.yml --project-name "$project" down --volumes >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
cleanup
PB_EXPOSE_PORT="$port" \
PB_SUPERUSER_EMAIL="$email" \
PB_SUPERUSER_PASSWORD="$password" \
docker compose --file apps/store/docker-compose.yml --project-name "$project" up --detach --build --wait
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
node apps/store/tests/workshop.integration.mjs; then
docker compose --file apps/store/docker-compose.yml --project-name "$project" logs --no-color workshop
exit 1
fi
-165
View File
@@ -1,165 +0,0 @@
import assert from "node:assert/strict";
import PocketBase, { ClientResponseError } from "pocketbase";
const baseUrl = process.env.WORKSHOP_TEST_URL;
const superuserEmail = process.env.WORKSHOP_TEST_SUPERUSER_EMAIL;
const superuserPassword = process.env.WORKSHOP_TEST_SUPERUSER_PASSWORD;
assert(baseUrl && superuserEmail && superuserPassword, "Workshop integration test environment is incomplete");
const root = new PocketBase(baseUrl);
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
const createUser = async (name, role = "member", state = "active") => {
const password = `Workshop${name}Password123!`;
return root.collection("users").create({
email: `${name.toLowerCase()}@homarr.test`,
password,
passwordConfirm: password,
verified: true,
displayName: name,
role,
state,
});
};
const expectStatus = async (operation, status) => {
const statuses = Array.isArray(status) ? status : [status];
await assert.rejects(operation, (error) => error instanceof ClientResponseError && statuses.includes(error.status));
};
const memberRecord = await createUser("Member");
const secondMemberRecord = await createUser("SecondMember");
const bannedRecord = await createUser("Banned", "member", "posting_banned");
const disabledRecord = await createUser("Disabled");
const moderatorRecord = await createUser("Moderator", "moderator");
const adminRecord = await createUser("Admin", "admin");
const member = await root.collection("users").impersonate(memberRecord.id, 3600);
const secondMember = await root.collection("users").impersonate(secondMemberRecord.id, 3600);
const banned = await root.collection("users").impersonate(bannedRecord.id, 3600);
const disabled = await root.collection("users").impersonate(disabledRecord.id, 3600);
const moderator = await root.collection("users").impersonate(moderatorRecord.id, 3600);
const admin = await root.collection("users").impersonate(adminRecord.id, 3600);
const forgedMember = await member.collection("users").update(memberRecord.id, {
role: "admin",
state: "posting_banned",
moderationReason: "self escalation",
});
assert.equal(forgedMember.role, "member", "members must not change their own role");
assert.equal(forgedMember.state, "active", "members must not change their own account state");
assert.equal(forgedMember.moderationReason, "", "members must not change protected moderation fields");
await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Integration disable" },
});
const submission = await member.collection("submissions").create({
type: "css",
title: "Integration theme",
description: "A backend integration fixture",
schemaVersion: "forged-version",
content: ":root { --homarr-test: #fff; }",
author: secondMemberRecord.id,
authorName: "Forged",
revision: 99,
});
assert.equal(submission.author, memberRecord.id, "server must enforce ownership");
assert.equal(submission.authorName, "Member", "server must snapshot the authenticated author name");
assert.equal(submission.revision, 1, "server must own the initial revision");
assert.equal(submission.schemaVersion, "homarr-custom-css-v1", "server must own schemaVersion");
await expectStatus(
() => banned.collection("submissions").create({ type: "css", title: "Blocked theme", content: "body {}" }),
403,
);
await expectStatus(() => disabled.collection("votes").create({ submission: submission.id, value: 1 }), [400, 403]);
await expectStatus(
() =>
member.collection("submissions").create({
type: "widget",
title: "Invalid widget",
content: JSON.stringify({ $schema: "homarr-custom-widget-v2", name: "Missing contract" }),
}),
400,
);
const vote = await member.collection("votes").create({ submission: submission.id, value: 1 });
await expectStatus(() => member.collection("votes").create({ submission: submission.id, value: -1 }), 400);
assert.equal(
(await member.collection("votes").update(vote.id, { submission: "forged", user: "forged", value: -1 })).submission,
submission.id,
);
const report = await secondMember.collection("reports").create({
submission: submission.id,
reporter: memberRecord.id,
category: "spam",
explanation: "Integration report",
status: "dismissed",
});
assert.equal(report.reporter, secondMemberRecord.id);
assert.equal(report.status, "open");
assert.equal((await member.collection("reports").getList()).totalItems, 0, "reports must remain private from members");
assert.equal((await moderator.collection("reports").getList()).items.length, 1);
await expectStatus(
() =>
member.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/state`, {
method: "POST",
body: { state: "posting_banned", reason: "No permission" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${adminRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Cannot alter admin" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Cannot promote" },
}),
403,
);
await admin.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Integration promotion" },
});
assert.equal((await root.collection("users").getOne(secondMemberRecord.id)).role, "moderator");
await moderator.send(`/api/workshop/moderation/reports/${report.id}/resolve`, {
method: "POST",
body: { status: "resolved", reason: "Integration resolution" },
});
assert.equal((await root.collection("reports").getOne(report.id)).status, "resolved");
await moderator.send(`/api/workshop/moderation/submissions/${submission.id}/remove`, {
method: "POST",
body: { reason: "Integration removal" },
});
await expectStatus(() => root.collection("submissions").getOne(submission.id), 404);
await expectStatus(() => root.collection("votes").getOne(vote.id), 404);
await expectStatus(() => root.collection("reports").getOne(report.id), 404);
const actions = await root.collection("moderation_actions").getFullList({ sort: "created" });
assert.deepEqual(
actions.map((action) => action.action),
["set_account_state", "set_role", "resolve_report", "remove_submission"],
);
assert(actions.at(-1)?.snapshot.includes("Integration theme"), "removal audit must retain a target snapshot");
const anonymous = new PocketBase(baseUrl);
const listings = await anonymous.collection("workshop_listings").getList();
assert.equal(listings.totalItems, 0);
await expectStatus(() => anonymous.collection("submissions").getList(), 403);
console.log("Workshop backend integration matrix passed");
-78
View File
@@ -1,78 +0,0 @@
# Workshop V2 audit
## Purpose
The Workshop is Homarr's central community catalog for custom widgets and CSS themes. It must let people discover, inspect, export, publish, update, report, and install creations from homarr.dev and from Homarr itself without requiring raw copy-and-paste as the default workflow.
### Feature North Star
A Homarr user can move from a useful local creation to a safe community submission, or from a community listing to an installed local creation, in one understandable flow with source visibility and an explicit confirmation boundary.
### Developer-experience North Star
Workshop behavior is defined once: one typed contract, one client, versioned PocketBase migrations, deterministic local startup, shared validation fixtures, typed failures, and UI components that contain presentation rather than PocketBase queries or authorization rules.
## Baseline audit
The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). This rewrite starts at `dev` commit `ba7718bde`.
| Area | Severity | Baseline finding | V2 resolution |
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Branch health | P0 | PR #5984 was based on an obsolete `dev` commit and conflicted with the current branch. | Rebuild from current `dev`; keep the old head on a backup ref. |
| Security | P0 | Submission validation ran in browsers and could be bypassed with direct PocketBase writes. | Enforce validation, ownership, account state, immutable fields, and upload limits in PocketBase hooks and rules. |
| Moderation | P0 | There were no moderator roles, posting bans, disabled accounts, least-privilege actions, or durable moderation history. | Add roles, account states, explicit moderation routes, a staff UI, and append-only actions. |
| Architecture | P1 | Docs and Next.js defined separate record types, queries, mutations, sorting, voting, and error behavior. | Add `@homarr/workshop` as the only public contract and API client. |
| Performance | P1 | The browser downloaded every submission and full content, then searched, sorted, and filtered locally. | Server-side pagination/filtering/sorting; summary views exclude content. |
| Product | P1 | Website and app flows were disconnected and sharing still depended on manual content transfer. | Add contextual install and direct share actions backed by canonical exports. |
| UX/UI | P1 | A large bespoke Docusaurus component set and a separate Mantine modal produced inconsistent interaction and state behavior. | Use each host's native components with shared flows, copy rules, contracts, and states. |
| Accessibility | P1 | Several custom interactive elements bypassed semantic controls and touch targets; focus and reduced-motion behavior was incomplete. | Require semantic controls, visible focus, keyboard operation, 44px touch targets, AA contrast, and reduced motion. |
| Operations | P1 | A third-party PocketBase image and incomplete bootstrap docs made builds and recovery non-deterministic. | Build a pinned official binary with checksum verification; document health, backup, restore, OAuth, and upgrades. |
| Testing | P1 | Tests covered a narrow client schema and did not exercise API rules, roles, auth states, moderation, or cross-surface flows. | Add contract, backend permission-matrix, UI, and end-to-end coverage. |
| Documentation | P2 | Documentation advertised a supported self-hosted Workshop even though V2 is a central Homarr service. | Document the central service and local-development overrides only. |
## Decisions
- Public name: **Workshop**.
- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`).
- Identity: GitHub OAuth only.
- Community features: votes and reports; comments are removed.
- Hosting: one Homarr-operated service at `store.homarr.dev`; overrides are development-only.
- Publishing: immediate, with source inspection, reporting, and staff removal.
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
## Launch acceptance
- [x] Anonymous users can browse, search, filter, inspect, copy, and download without receiving full content in listing responses.
- [x] Members can publish, update, delete, vote, and report; ownership and schemas are enforced server-side.
- [x] Posting-banned users cannot create or update submissions but can browse, vote, and report.
- [x] Disabled accounts cannot authenticate or write, including with an existing token.
- [x] Moderators can resolve reports, remove submissions, posting-ban users, and disable/restore non-admin users.
- [x] Only admins can change roles; staff actions are append-only and attributable.
- [x] Homarr can export/share local widgets and CSS and validate/confirm Workshop installs.
- [x] Website and app implement loading, empty, offline, validation, permission, and destructive-action states.
- [x] Keyboard semantics, mobile layout, dark mode, reduced motion, and horizontal overflow received a browser smoke test.
- [x] Clean PocketBase startup, migration, health, backup, and restore procedures are reproducible and documented.
- [ ] Add automated authenticated cross-surface E2E coverage before changing the PR from draft to ready.
## Deferred after V2
Comments, private Workshop deployments, automated approval queues, follows, collections, notifications, arbitrary package types, historical version installation, and personalized recommendations.
## Exit scorecard
The baseline has three P0, seven P1, and one P2 findings. The rewrite closes every product, security, architecture, moderation, performance, operations, documentation, UX, and DX finding. One P1 release-verification item remains: automated authenticated E2E across both public and in-app surfaces. Keep PR #5984 in draft until it lands.
| Gate | Result | Evidence |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. |
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
| Backend operations | Pass | PocketBase 0.39.6 image builds with checksum verification; clean migration, health, and listing/detail access boundaries pass. |
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. |
| Browser smoke | Pass | Desktop and 390px mobile discovery render without console errors or horizontal overflow; semantic labels, skip link, dark mode, and reduced motion are active. |
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass.
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.10.0"
"pnpm": ">=11.13.1"
},
"packageManager": "pnpm@11.10.0"
"packageManager": "pnpm@11.13.1"
}
+3 -5
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.70.0",
"version": "1.71.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -21,7 +21,6 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:workshop": "docker compose -f apps/store/docker-compose.yml up --build",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
@@ -34,7 +33,6 @@
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:workshop-backend": "bash apps/store/tests/run-integration.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
@@ -71,7 +69,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.10.0"
"pnpm": ">=11.13.1"
},
"packageManager": "pnpm@11.10.0"
"packageManager": "pnpm@11.13.1"
}
+2 -1
View File
@@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
getPaginated: permissionRequiredProcedure
.requiresPermission("board-modify-all")
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({
+1 -1
View File
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
});
}
if (ctx.session?.user) {
if (ctx.session?.user.permissions.includes("board-modify-all")) {
statistics.push({
titleKey: "app",
subtitleKey: "resources",
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db";
import { and, asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
@@ -130,9 +130,15 @@ export const searchEngineRouter = createTRPCRouter({
return null;
}),
search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
// Public dashboards have no session: restrict anonymous users to generic
// (non-integration) engines so custom search engines work there too (#4132),
// while integration-backed engines stay available only when signed in.
where: and(
like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
),
with: {
integration: {
columns: {
+27
View File
@@ -76,6 +76,33 @@ describe("all should return all apps", () => {
});
});
describe("getPaginated should require board modification access", () => {
test("should throw FORBIDDEN without board-modify-all", async () => {
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
});
test("should return apps with board-modify-all", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["board-modify-all"]),
});
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
totalCount: 1,
items: [{ id: "1" }],
});
});
});
describe("byId should return an app by id", () => {
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
// Arrange
+77
View File
@@ -357,6 +357,83 @@ describe("delete should delete user", () => {
});
});
describe("changeEnableRightClickOnWidgets should toggle the right-click preference", () => {
test("non-admin can toggle their own preference", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(users).values({
id: defaultOwnerId,
name: "owner",
});
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: false });
const updated = await db.query.users.findFirst({
where: eq(users.id, defaultOwnerId),
columns: { enableRightClickOnWidgets: true },
});
expect(updated?.enableRightClickOnWidgets).toBe(false);
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: true });
const restored = await db.query.users.findFirst({
where: eq(users.id, defaultOwnerId),
columns: { enableRightClickOnWidgets: true },
});
expect(restored?.enableRightClickOnWidgets).toBe(true);
});
test("non-admin cannot toggle another user's preference", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const otherUserId = createId();
await db.insert(users).values({ id: defaultOwnerId });
await db.insert(users).values({ id: otherUserId, name: "other" });
await expect(
caller.changeEnableRightClickOnWidgets({ id: otherUserId, enableRightClickOnWidgets: false }),
).rejects.toThrow("User not found");
const other = await db.query.users.findFirst({
where: eq(users.id, otherUserId),
columns: { enableRightClickOnWidgets: true },
});
expect(other?.enableRightClickOnWidgets).toBe(true);
});
test("admin can toggle another user's preference", async () => {
const db = createDb();
const adminSession = createSession(["admin"]);
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: adminSession,
});
const targetUserId = createId();
await db.insert(users).values({ id: defaultOwnerId });
await db.insert(users).values({ id: targetUserId, name: "target" });
await caller.changeEnableRightClickOnWidgets({ id: targetUserId, enableRightClickOnWidgets: false });
const target = await db.query.users.findFirst({
where: eq(users.id, targetUserId),
columns: { enableRightClickOnWidgets: true },
});
expect(target?.enableRightClickOnWidgets).toBe(false);
});
});
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
await db.insert(onboarding).values({
id: createId(),
+29
View File
@@ -23,6 +23,7 @@ import {
userCreateSchema,
userDdgBangsSchema,
userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema,
userInitSchema,
userPingIconsEnabledSchema,
@@ -345,6 +346,7 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -381,6 +383,7 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -651,6 +654,32 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
changeEnableRightClickOnWidgets: protectedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/users/right-click-widgets",
tags: ["users"],
protect: true,
},
})
.input(convertIntersectionToZodObject(userEnableRightClickOnWidgetsSchema.and(byIdSchema)))
.output(z.void())
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
})
.where(eq(users.id, input.id));
}),
changePingIconsEnabled: protectedProcedure
.input(userPingIconsEnabledSchema.and(byIdSchema))
.mutation(async ({ input, ctx }) => {
+1 -1
View File
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
.concat(createImmichIntegrationMiddleware("query"))
.input(
z.object({
albumId: z.string(),
albumId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
@@ -3,7 +3,10 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import {
mediaRequestListInputSchema,
mediaRequestListRequestHandler,
} from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -16,13 +19,14 @@ export const mediaRequestsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
return results
+7 -4
View File
@@ -3,6 +3,7 @@ import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions";
@@ -11,6 +12,8 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -71,13 +74,13 @@ export const releasesRouter = createTRPCRouter({
for (const secret of secrets) {
try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch {
// Skip corrupt secrets
} catch (error) {
logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error });
}
}
}
} catch {
// Access denied or table not yet migrated -- proceed without tokens
} catch (error) {
logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error });
}
}
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `enable_right_click_on_widgets` boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -302,6 +302,13 @@
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1783852800000,
"tag": "0043_add_right_click_preference",
"breakpoints": true
}
]
}
}
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "enable_right_click_on_widgets" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -64,6 +64,13 @@
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1783786677702,
"tag": "0009_add_right_click_preference",
"breakpoints": true
}
]
}
}
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `enable_right_click_on_widgets` integer DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -288,6 +288,13 @@
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786658943,
"tag": "0041_add_right_click_preference",
"breakpoints": true
}
]
}
}
+1
View File
@@ -86,6 +86,7 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
+1
View File
@@ -85,6 +85,7 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
+1
View File
@@ -68,6 +68,7 @@ export const users = sqliteTable("user", {
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
});
+1 -1
View File
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
connection.end();
await mysqlContainer.stop();
}, 40_000);
}, 120_000);
});
-1
View File
@@ -467,7 +467,6 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
documentationUrl: null,
defaultUrl: "http://localhost:8001",
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
+31 -8
View File
@@ -19,6 +19,12 @@ const createUrlByIdChannel = (id: string) =>
headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`);
export type ForwardImageResult =
| { image: ArrayBuffer; contentType: string | null }
| { error: "not-found" }
| { error: "upstream-error"; statusCode: number }
| { error: "fetch-error" };
export class ImageProxy {
private static hmacKey: Buffer<ArrayBuffer> | null = null;
@@ -47,17 +53,34 @@ export class ImageProxy {
return this.createImageUrl(id);
}
public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> {
public async forwardImageAsync(id: string): Promise<ForwardImageResult> {
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
if (!urlAndHeaders) {
return null;
return { error: "not-found" };
}
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
const proxyUrl = this.createImageUrl(id);
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
try {
response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
} catch (error) {
logger.error(
new ErrorWithMetadata(
"Failed to connect to image source",
{
id,
url: this.redactUrl(urlAndHeaders.url),
headers: this.redactHeaders(urlAndHeaders.headers),
proxyUrl,
},
{ cause: error },
),
);
return { error: "fetch-error" };
}
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to fetch image", {
@@ -68,7 +91,7 @@ export class ImageProxy {
statusCode: response.status,
}),
);
return null;
return { error: "upstream-error", statusCode: response.status };
}
const arrayBuffer = await response.arrayBuffer();
@@ -80,7 +103,7 @@ export class ImageProxy {
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
});
return arrayBuffer;
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
}
private createImageUrl(id: string): string {
@@ -117,6 +117,12 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
};
}
@@ -1,10 +1,14 @@
import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAlbumInfo,
getAllAlbums,
getMyUser,
getServerStatistics,
init,
searchAssets,
searchRandom,
searchUsers,
} from "@immich/sdk";
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
@@ -42,6 +46,7 @@ export interface ImmichAsset {
}
const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
export class ImmichIntegration extends Integration {
constructor(integration: IntegrationInput) {
@@ -62,36 +67,36 @@ export class ImmichIntegration extends Integration {
};
}
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
this.initClient();
const requestOptions = this.getRequestOptions();
if (!albumId) {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
},
requestOptions,
);
return {
id: "",
albumName: "",
assets: await this.createAssetsAsync(assets),
};
}
const [album, albumAssets] = await Promise.all([
getAlbumInfo({ id: albumId }, requestOptions),
this.searchAlbumAssetsAsync(albumId),
]);
const imageProxy = new ImageProxy();
return {
albumName: album.albumName,
assets: await Promise.all(
albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
this.url(`/api/assets/${asset.id}/original`).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
),
assets: await this.createAssetsAsync(albumAssets),
id: album.id,
};
}
@@ -133,6 +138,32 @@ export class ImmichIntegration extends Integration {
return assets;
}
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
const imageProxy = new ImageProxy();
return await Promise.all(
assets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
);
}
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return {
// Undici and node types are not the same
@@ -13,6 +13,12 @@ export interface StreamSession {
episodeName?: string | null;
albumName?: string | null;
episodeCount?: number | null;
playback: {
state: "playing" | "paused" | "buffering" | null;
positionMs: number | null;
durationMs: number | null;
};
location: "lan" | "wan" | null;
metadata: {
video: {
resolution: {
@@ -17,6 +17,10 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
function ticksToMs(ticks: number | null | undefined): number | null {
return ticks ? Math.round(ticks / 10_000) : null;
}
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -50,6 +54,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
@@ -57,6 +64,12 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
positionMs,
durationMs,
},
location: null,
metadata: {
video: {
resolution:
@@ -13,6 +13,8 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -27,6 +29,8 @@ const sessions: StreamSession[] = [
episodeName: "Ozymandias",
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -41,6 +45,8 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -55,6 +61,8 @@ const sessions: StreamSession[] = [
episodeName: "The We We Are",
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -69,6 +77,8 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: "Random Access Memories",
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -83,6 +93,8 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -153,9 +153,9 @@ export class OverseerrIntegration
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return Promise.all([]);
else return [];
return await Promise.all(
const settled = await Promise.allSettled(
requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
@@ -184,6 +184,10 @@ export class OverseerrIntegration
};
}),
);
return settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
}
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -301,6 +305,10 @@ export class OverseerrIntegration
},
});
if (!response.ok) {
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
}
if (type === "tv") {
const series = (await response.json()) as TvInformation;
return {
@@ -3,6 +3,54 @@ interface MediaContainer {
Track?: Session[];
}
interface Stream {
$: {
streamType: string;
codec?: string;
language?: string;
selected?: string;
width?: string;
height?: string;
frameRate?: string;
channels?: string;
};
}
interface Part {
Stream?: Stream[];
$: {
container?: string;
};
}
interface Media {
Part?: Part[];
$: {
videoResolution?: string;
videoCodec?: string;
audioCodec?: string;
bitrate?: string;
container?: string;
};
}
interface TranscodeSession {
$: {
throttled?: string;
progress?: string;
speed?: string;
videoDecision?: string;
audioDecision?: string;
sourceVideoCodec?: string;
sourceAudioCodec?: string;
videoCodec?: string;
audioCodec?: string;
container?: string;
width?: string;
height?: string;
};
}
interface Session {
User?: {
$: {
@@ -15,13 +63,20 @@ interface Session {
$: {
product: string;
title: string;
address?: string;
state?: string;
local?: string;
};
}[];
Session?: {
$: {
id: string;
bandwidth?: string;
location?: string;
};
}[];
TranscodeSession?: TranscodeSession[];
Media?: Media[];
$: {
grandparentTitle?: string;
parentTitle?: string;
@@ -29,6 +84,8 @@ interface Session {
index?: number;
type: string;
live?: string;
viewOffset?: string;
duration?: string;
};
}
@@ -17,6 +17,32 @@ import type { PlexResponse } from "./interface";
const logger = createLogger({ module: "plexIntegration" });
function parseOptionalNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
function parseLocation(value: string | undefined): "lan" | "wan" | null {
return value === "lan" || value === "wan" ? value : null;
}
function parsePlaybackState(value: string | undefined): "playing" | "paused" | "buffering" | null {
return value === "playing" || value === "paused" || value === "buffering" ? value : null;
}
function parseResolution(
width: string | undefined,
height: string | undefined,
): { width: number; height: number } | null {
const parsedWidth = parseOptionalNumber(width);
const parsedHeight = parseOptionalNumber(height);
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
}
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");
@@ -43,11 +69,24 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const userElement = mediaElement.User ? mediaElement.User[0] : undefined;
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined;
const transcodeElement = mediaElement.TranscodeSession ? mediaElement.TranscodeSession[0] : undefined;
const mediaInfoElement = mediaElement.Media ? mediaElement.Media[0] : undefined;
if (!playerElement) {
return undefined;
}
const positionMs = parseOptionalNumber(mediaElement.$.viewOffset);
const durationMs = parseOptionalNumber(mediaElement.$.duration);
const playbackState = parsePlaybackState(playerElement.$.state);
const location = parseLocation(sessionElement?.$.location);
const streams = mediaInfoElement?.Part?.[0]?.Stream ?? [];
const videoStream = streams.find((stream) => stream.$.streamType === "1");
const audioStream = streams.find((stream) => stream.$.streamType === "2");
return {
sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -63,7 +102,30 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
episodeName: mediaElement.$.title ?? null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: mediaElement.$.index ?? null,
metadata: null,
playback: {
state: playbackState,
positionMs,
durationMs,
},
location,
metadata: {
video: {
resolution: parseResolution(videoStream?.$.width, videoStream?.$.height),
frameRate: parseOptionalNumber(videoStream?.$.frameRate),
},
audio: {
channelCount: parseOptionalNumber(audioStream?.$.channels),
codec: audioStream?.$.codec ?? mediaInfoElement?.$.audioCodec ?? null,
},
transcoding: {
container: transcodeElement?.$.container ?? null,
resolution: parseResolution(transcodeElement?.$.width, transcodeElement?.$.height),
target: {
audioCodec: transcodeElement?.$.audioCodec ?? null,
videoCodec: transcodeElement?.$.videoCodec ?? null,
},
},
},
},
};
})
@@ -23,6 +23,8 @@ type OptionMapping = {
const optionMapping: OptionMapping = {
"mediaRequests-requestList": {
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
statusFilter: () => undefined,
recentDays: () => undefined,
},
"mediaRequests-requestStats": {},
bookmarks: {
+4 -17
View File
@@ -300,10 +300,7 @@ async function getContainersWithStatsAsync() {
const stats = await instance
.getContainer(container.Id)
// Not in one-shot mode: the daemon then includes precpu_stats (the
// previous reading), which is required to compute the current CPU usage
// as a delta. One-shot omits it and would only expose lifetime totals.
.stats({ stream: false })
.stats({ stream: false, "one-shot": true })
.catch(
() =>
({
@@ -339,22 +336,12 @@ export function calculateCpuUsage(stats: ContainerStats): number {
}
const numberOfCpus = stats.cpu_stats.online_cpus;
// Docker's own formula for the current (instantaneous) CPU usage: it compares
// the latest reading against the previous one (precpu_stats). Dividing the
// cumulative total_usage by the cumulative system_cpu_usage instead would
// report the container's lifetime average, which stays high long after a load
// spike has ended. precpu_stats is only present when the stats are NOT fetched
// in one-shot mode; when it is absent (e.g. Podman) the deltas fall back to the
// cumulative totals, matching the previous behaviour.
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
if (systemDelta <= 0 || cpuDelta < 0) {
const usage = stats.cpu_stats.system_cpu_usage;
if (!usage || usage === 0) {
return 0;
}
return (cpuDelta / systemDelta) * numberOfCpus * 100;
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
}
export function calculateMemoryUsage(stats: ContainerStats): number {
+1 -1
View File
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId: string }
{ albumId?: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
@@ -13,6 +13,7 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
}
export const createIntegrationRequestHandler = <
@@ -32,6 +33,7 @@ export const createIntegrationRequestHandler = <
return options.requestAsync(integration, input.options);
},
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
});
return {
@@ -1,6 +1,7 @@
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
@@ -35,7 +36,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached) cache.delete(key);
const existing = inflight.get(key);
if (existing) return existing;
@@ -55,6 +55,9 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
})
.catch((err) => {
inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
return cached;
}
throw err;
});
@@ -1,16 +1,44 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest } from "@homarr/integrations/types";
import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const mediaRequestStatusValues = [
"pending",
"approved",
"declined",
"failed",
"completed",
] as const satisfies readonly MediaRequestStatus[];
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export const mediaRequestListInputSchema = z.object({
statuses: z.array(z.enum(mediaRequestStatusValues)).nonempty(),
recentDays: z.number().min(0).max(365),
});
export type MediaRequestListInput = z.infer<typeof mediaRequestListInputSchema>;
export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
MediaRequest[],
IntegrationKindByCategory<"mediaRequest">,
Record<string, never>
MediaRequestListInput
>({
async requestAsync(integration, _input) {
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getRequestsAsync();
const requests = await integrationInstance.getRequestsAsync();
const cutoff = input.recentDays > 0 ? Date.now() - input.recentDays * MS_PER_DAY : null;
const statusSet = new Set<MediaRequestStatus>(input.statuses);
return requests.filter((request) => {
if (!statusSet.has(request.status)) return false;
if (cutoff !== null && request.createdAt.getTime() < cutoff) return false;
return true;
});
},
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
});
@@ -16,4 +16,6 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
users: await integrationInstance.getUsersAsync(),
};
},
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
});
@@ -134,7 +134,33 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
baseUrl,
auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { enabled: true },
throttle: {
enabled: true,
onRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
onSecondaryRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub secondary rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
},
userAgent,
});
@@ -719,6 +745,19 @@ const linuxServerIOReleasesSchema = z.object({
}),
});
// Parse a github.com project URL (as returned by the LSIO registry) into an
// "owner/repo" identifier, or null if it is not a github.com repository URL.
const parseGithubRepo = (url: string): string | null => {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const [owner, repo] = parsed.pathname.replace(/^\/+/, "").split("/");
return owner && repo ? `${owner}/${repo}` : null;
} catch {
return null;
}
};
const getLinuxServerIOReleaseAsync = async (
baseUrl: string,
identifier: string,
@@ -748,6 +787,29 @@ const getLinuxServerIOReleaseAsync = async (
error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
};
// LSIO's /api/v1/images only exposes the single latest version, so a version
// filter can never surface an older release (#4351). When the image has a GitHub
// repository, delegate to the GitHub release provider to fetch the real release
// history and apply the filter, while keeping LSIO's own image metadata. Any
// GitHub failure (no repo, no releases, rate limit) falls back to the LSIO latest.
const githubRepo = parseGithubRepo(release.github_url);
if (githubRepo) {
const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex);
if (githubResult.success) {
return {
success: true,
data: {
...githubResult.data,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
starsCount: release.stars,
createdAt: release.initial_date ?? githubResult.data.createdAt,
},
};
}
}
const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) {
return {
@@ -75,26 +75,6 @@ describe("calculateCpuUsage", () => {
// (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(1);
});
test("should use the delta against precpu_stats for the current usage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// Despite a large lifetime total_usage, only the recent delta counts:
// (2000 / 10000) * 4 * 100 = 80
expect(calculateCpuUsage(stats)).toBe(80);
});
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// No CPU delta since the last reading -> current usage is 0, even though the
// lifetime average would be high. This is the regression the fix addresses.
expect(calculateCpuUsage(stats)).toBe(0);
});
});
describe("calculateMemoryUsage", () => {
+4 -1
View File
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max&current_weather=true&timezone=auto`,
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`,
{ signal },
);
});
@@ -27,6 +27,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
};
}),
} satisfies Weather;
@@ -48,6 +49,7 @@ const atLocationOutput = z.object({
sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
}),
});
@@ -66,5 +68,6 @@ export interface Weather {
sunset: string | undefined;
maxWindSpeed: number | undefined;
maxWindGusts: number | undefined;
humidity: number | undefined;
}[];
}
+3
View File
@@ -10,6 +10,7 @@ export type SettingsContextProps = Pick<
| "openSearchInNewTab"
| "ddgBangs"
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
> &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">;
@@ -32,6 +33,7 @@ export type UserSettings = Pick<
| "openSearchInNewTab"
| "ddgBangs"
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
>;
export const createSettings = ({
@@ -48,6 +50,7 @@ export const createSettings = ({
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
@@ -56,6 +56,13 @@ export const userPreferenceDefinitions = [
mutationGroup: "pingIconsEnabled",
aliases: ["ping", "icon", "status"],
},
{
key: "enableRightClickOnWidgets",
kind: "boolean",
guest: false,
mutationGroup: "enableRightClickOnWidgets",
aliases: ["right click", "context menu", "tile", "widget menu"],
},
{
key: "fullPreferencesPage",
kind: "link",
@@ -17,6 +17,10 @@ export const appIntegrationBoardMode = {
return groups;
}
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
return groups.concat(
session.user.permissions.includes("board-modify-all")
? [appsSearchGroup, integrationsSearchGroup]
: [integrationsSearchGroup],
);
},
} satisfies SearchMode;
@@ -87,7 +87,8 @@ export const globalCommandGroup = createGroup<Command>({
icon: IconBox,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: !session?.user.permissions.includes("app-create"),
hidden:
!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"),
},
{
commandKey: "newIntegration",
@@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconBox,
path: "/manage/apps",
name: t("manageApp.label"),
hidden: !session,
hidden: !session?.user.permissions.includes("board-modify-all"),
},
{
icon: IconPlug,
@@ -3,6 +3,7 @@
import {
IconActivity,
IconCalendarWeek,
IconClick,
IconDeviceDesktop,
IconExternalLink,
IconHome,
@@ -46,6 +47,7 @@ export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity,
enableRightClickOnWidgets: IconClick,
fullPreferencesPage: IconSettings,
};
@@ -93,6 +93,7 @@ export const useUserPreferences = () => {
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
const rightClickMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation({ onSuccess: refresh });
const getEffective = (key: UserPreferenceKey): unknown =>
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
@@ -116,6 +117,8 @@ export const useUserPreferences = () => {
}),
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
enableRightClickOnWidgets: (uid, _key, value) =>
rightClickMutation.mutateAsync({ id: uid, enableRightClickOnWidgets: value as boolean }),
};
const groupPending: Record<string, boolean> = {
@@ -123,6 +126,7 @@ export const useUserPreferences = () => {
homeBoards: homeBoardsMutation.isPending,
firstDayOfWeek: firstDayMutation.isPending,
pingIconsEnabled: pingMutation.isPending,
enableRightClickOnWidgets: rightClickMutation.isPending,
};
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more