Compare commits

..
Author SHA1 Message Date
ajnart 89260b8754 chore(workshop): merge latest dev 2026-07-06 10:20:41 +02:00
Thomas 2e8fddd29f fix(docs): fix docs build — MDX acorn error and CSS @import ordering (#6217) 2026-07-06 10:14:36 +02:00
homarr-releases[bot] 302d5a0d04 chore: update bug report template (#6181) 2026-07-06 10:14:15 +02:00
ThomasandReleases Homarr a9ed95856a fix(layout): raise AppShellHeader z-index above Indicator dots (#6218)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-07-06 10:13:54 +02:00
Thomasandhomarr-renovate[bot] 4d42eb65e4 chore(deps): mega dependency update (#6212)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-06 10:05:51 +02:00
Thomas 82858ecd75 fix(forms): break AppForm infinite render loop on edit page (#6198) (#6215) 2026-07-06 10:03:22 +02:00
homarr-crowdin[bot] 0e365ed53c chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-07-06 02:04:20 +00:00
ajnart 8bf2171eaa chore(workshop): merge latest dev 2026-07-06 02:34:29 +02:00
ajnart 420633f125 Merge remote-tracking branch 'origin/feat/widget-store' into feat/widget-store 2026-07-06 02:31:42 +02:00
ajnart 54871bf22e fix(workshop): use shared dayjs formatting 2026-07-06 02:31:22 +02:00
Thomas a55dc98487 Remove memory regression job from code quality workflow
Removed memory regression testing steps from the workflow.
2026-07-06 02:22:05 +02:00
homarr-crowdin[bot] 5b91687912 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-07-05 01:57:18 +00:00
Thomas a3df31ff05 Revise Immich API key permissions in documentation
Updated API key permissions for Immich integration documentation.
2026-07-04 21:49:08 +02:00
homarr-renovate[bot] c5c9daf5a5 chore(deps): update dependency undici to v7.28.0 [security] (#6188)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 18:11:47 +00:00
homarr-renovate[bot] ab0e878fbf chore(deps): update dependency posthog-js to ^1.396.2 (#6189)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 16:20:03 +00:00
Thomas 885be1b9a1 fix(widget-secrets): add missing widget_secret migration and add error notifications (#6192)
* fix(widget-secrets): add missing widget_secret migration and add error notifications

The widget_secret table was defined in all three Drizzle schema files but
no SQL migration was ever generated to create it, causing setSecret to
fail with 'no such table: widget_secret' in production (drizzle-kit push
in development hid the problem).

- Add migration SQL for sqlite (0040), mysql (0042), postgresql (0008)
- Update journal entries for all three dialects
- Add onError handlers to setSecret/deleteSecret mutations so errors
  are shown as red notifications instead of silently swallowed

Fixes #6186

* fix(widget-secrets): add missing drizzle snapshots for merge-url and widget_secret migrations

The merge_custom_widget_url migration (0039/0041/0007) was committed
without a corresponding snapshot, breaking drizzle-kit generate for
all future migrations. This adds:
- 0039/0041/0007 snapshots reflecting the merge-url changes
  (base_url+endpoint -> url, add enabled column)
- 0040/0042/0008 snapshots reflecting the widget_secret table

All snapshot chains now match journal entries (41/43/9 entries each).
2026-07-04 17:57:40 +02:00
homarr-renovate[bot] 0f8a86fdde fix(deps): update tailwindcss monorepo to ^4.3.2 (#6190)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 14:55:47 +00:00
homarr-renovate[bot] 59c084bc57 chore(deps): update testcontainers-node monorepo to ^12.0.4 (#6185)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 12:18:18 +00:00
homarr-renovate[bot] f6cf262bab chore(deps): update dependency posthog-js to ^1.396.1 (#6183)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 10:27:08 +00:00
Releases Homarr ba860aacaa chore(release): 1.69.1 [skip ci] 2026-07-04 09:02:40 +00:00
homarr-releases[bot] 161dafbdb8 chore(release): automatic release v1.69.1 2026-07-04 09:01:34 +00:00
Thomas a382c75968 fix: unblock query cache mutations in demo mode and add cache buster (#6176) 2026-07-04 10:47:09 +02:00
homarr-renovate[bot] 529942e4da chore(deps): update dependency brace-expansion@>=1.0.0 <=1.1.11 to >=5.0.7 (#6173)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 07:55:55 +00:00
copilot-swe-agent[bot] e6f8174a44 fix(workshop): encode screenshot filenames in URLs and stabilize paste effect deps 2026-07-04 07:18:18 +00:00
ajnart 74289b8b01 fix(workspace): approve parcel watcher build 2026-07-04 09:13:41 +02:00
homarr-releases[bot] e20053b2c1 chore: update bug report template (#6134) 2026-07-04 09:09:59 +02:00
134106b82c Main (#6175)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-07-04 09:09:23 +02:00
homarr-crowdin[bot] 7635031fdf chore(lang): updated translations from crowdin (#6143) 2026-07-04 08:58:20 +02:00
Bergasha b663dae409 fix (Calendar Widget): Add superjson serialization to query cache persister (#6172) 2026-07-04 08:57:49 +02:00
homarr-renovate[bot] 2c2bdb6b76 chore(deps): update dependency brace-expansion@>=2.0.0 <=2.0.1 to >=5.0.7 (#6174)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-04 04:45:04 +00:00
ajnart 4e2813d23e chore(workshop): merge latest dev 2026-07-03 23:01:04 +02:00
ajnart 8938cb6cf7 Merge remote-tracking branch 'origin/feat/widget-store' into feat/widget-store 2026-07-03 22:59:44 +02:00
ajnart 2fb92b7816 fix(workshop): harden store auth refresh 2026-07-03 22:59:08 +02:00
copilot-swe-agent[bot] cff2e08b92 docs: apply reviewer wording suggestion for workshop safety warning 2026-07-03 20:53:27 +00:00
ajnart f1e4866a8b fix(workshop): resolve remaining review feedback 2026-07-03 22:51:46 +02:00
ajnart b0429f9287 fix(workshop): address review feedback and merge dev 2026-07-03 22:45:05 +02:00
homarr-renovate[bot] dee9cd7aac chore(deps): update dependency lucide-react to ^1.22.0 (#6171)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-03 20:27:36 +00:00
homarr-renovate[bot] 7c222bf5e8 chore(deps): update dependency fastify to ^5.9.0 (#6166)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-03 20:26:10 +00:00
homarr-renovate[bot] f914cc26be chore(deps): update mantine monorepo to ^9.4.1 (#6165)
* chore(deps): update mantine monorepo to ^9.4.1

* style: auto-fix lint and format

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-03 20:15:37 +00:00
Releases Homarr 6f47a05cc2 chore(release): 1.69.0 [skip ci] 2026-07-03 18:08:44 +00:00
homarr-releases[bot] d8d5894b28 chore(release): automatic release v1.69.0 2026-07-03 18:07:40 +00:00
homarr-renovate[bot] 9252fdc139 fix(deps): update dependency postcss to ^8.5.16 (#6169)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-03 16:36:25 +00:00
Thomas f19033e0d5 feat: simplify caching — TanStack Query as single source of truth (#6075) 2026-07-03 16:33:03 +02:00
Thomas a5ec880eec fix(immich): v3 API compatibility — use searchAssets for album assets, drop deviceAssetId (#5977) 2026-07-03 15:59:08 +02:00
homarr-update-contributors[bot] b588672709 chore: update contributors (#6167)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-07-03 13:58:25 +00:00
homarr-update-contributors[bot] ceed5c6751 chore: update integrations list readme (#6085)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-07-02 15:47:49 +00:00
Thomas f7ede23dc3 Merge pull request #6089 from homarr-labs/feat/widget-only-integration-refactor 2026-07-02 17:37:07 +02:00
homarr-renovate[bot] 1b50249ea9 chore(deps): update dependency tar-fs@>=3.0.0 <3.0.9 to >=3.1.3 (#6162)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-02 14:11:50 +00:00
homarr-renovate[bot] 1d57519005 chore(deps): update dependency tar-fs@>=2.0.0 <2.1.3 to >=3.1.3 (#6161)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-02 11:44:07 +00:00
homarr-renovate[bot] dd2eb3aa66 chore(deps): update dependency posthog-js to ^1.395.0 (#6150)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-01 15:51:52 +00:00
Thomas Camlong 2c1a5c92e8 chore(i18n): revert non-English translations to dev
Keep translation changes in en.json only; Crowdin handles other locales.
2026-07-01 17:25:43 +02:00
ajnart 0425bec2ca style: auto-fix lint and format 2026-07-01 13:00:06 +00:00
Thomas Camlong 06da6191cc Merge origin/dev into feat/widget-only-integration-refactor
Resolve integration.ts conflict (keep widget-only documentationUrl: null, take defaultUrl from dev) and regenerate pnpm-lock.yaml.
2026-07-01 14:57:00 +02:00
homarr-renovate[bot] aec6bb18c5 chore(deps): update dependency @scalar/api-reference-react to ^0.9.49 (#6146)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-01 12:21:19 +00:00
Thomas f9da71a19b fix: quick wins — calendar, about, glances, admin UX, releases, iframe, notes (#6116) 2026-07-01 13:57:39 +02:00
homarr-renovate[bot] b83c8b6546 chore(deps): update dependency posthog-js to ^1.393.5 (#6135)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 21:53:10 +00:00
homarr-renovate[bot] 0e4189f693 chore(deps): update dependency dockerode to ^5.0.1 (#6130)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 20:09:36 +00:00
homarr-renovate[bot] 1cf010e9fb chore(deps): update node.js to v24.18.0 (#6107)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 18:11:58 +00:00
ajnart d670b69396 fix(timetable): restore station search by removing disabled query guard
The refactor from integration-based to option-based config changed the
useQuery enabled condition from 'integrationIds.length > 0' (always true
when an integration was linked) to 'query.length > 0'. The combination
of the debounced search value and the enabled toggle inside the nested
useOptions callback caused the query to never fire, making stations
unsearchable in the edit form.

Removing the enabled guard lets the query always run. The 300ms debounce
already prevents excessive requests.
2026-06-30 18:40:27 +02:00
Thomasandgithub-actions[bot] 7e58858353 feat(widgets): right-click context menu with auto-generated toggle actions (#6126)
* docs(helm): Update Helm chart documentation

* feat(widgets): add right-click context menu with auto-generated toggle actions

- Add WidgetContextMenu component using Mantine 9.3 Menu.ContextMenu
- Extend WidgetDefinition with contextActions API for widget-specific actions
- Auto-generate context menu toggle items for switch-type widget options
- Add 'Search' context action to media-requests widgets (list + stats)
- Keep existing dots-button menu for discoverability in edit mode
- Add widgetStateRef to WidgetComponentProps for client-side-only state
- Add skipContextMenu opt-out on switch option definitions

* style: auto-fix lint and format

* fix(widgets): address CodeRabbit review — badge in context menu target, edit-only pending gate

- Move title badge inside WidgetContextMenu wrapper so right-clicking the title overlay also triggers the context menu
- Remove full-menu hide during integration loading; only disable 'Edit' action while isPending
- Add isPending to allActions useMemo dependency array

* style: auto-fix lint and format

* fix(widgets): gate context menu behind authenticated session

- Only show right-click context menu for logged-in users
- Unauthenticated users see plain children without menu wrapper
- Matches existing BoardItemMenu session gating pattern

* style: auto-fix lint and format

* chore: revert unrelated sitemap change from auto-fix

* style: auto-fix lint and format

* fix(context-menu): persist widget option changes to server

Toggle switches and edit modal changes from the context menu now call
saveBoard mutation to persist to DB. Previously only the client cache
was updated, losing changes on reload. Removes unnecessary invalidation
that caused cascading refetch loops.

* style: auto-fix lint and format

* fix: use explicit React type imports instead of global namespace

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-30 18:10:08 +02:00
9ed55d57dd Main (#6138)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-30 17:52:37 +02:00
Releases Homarr 46ae756d15 chore(release): 1.68.1 [skip ci] 2026-06-30 12:02:44 +00:00
homarr-crowdin[bot] 8cac0b11c1 chore(lang): updated translations from crowdin (#6061) 2026-06-30 14:02:29 +02:00
Thomas 8b63799f1b Merge pull request #6124 from homarr-labs/fix/source-maps-memory-regression
fix: make production source maps opt-in to restore v1.67.0 memory baseline
2026-06-30 14:01:42 +02:00
homarr-renovate[bot] 5f011e8456 chore(deps): update turbo monorepo to ^2.10.0 (#6129)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-29 20:22:40 +00:00
homarr-renovate[bot] 9e1b529288 chore(deps): update dependency nanoid@>=4.0.0 <5.0.9 to >=5.1.16 (#6131)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-29 20:14:32 +00:00
homarr-renovate[bot] 4ad115dd2d chore(deps): update dependency posthog-js to ^1.393.4 (#6127)
* chore(deps): update dependency posthog-js to ^1.393.4

* style: auto-fix lint and format

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-29 20:01:36 +00:00
27209a5dde chore: add integration CODEOWNERS (#6086)
Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
2026-06-29 18:56:44 +02:00
ajnart 60a69e31ef fix: revert production source maps to restore v1.67.0 memory baseline
CodeRabbit review addressed:
- Removed the env-var gating (simpler: just remove source maps entirely)
- CI health check now fails fast if container never becomes healthy

Changes vs origin/main:
- next.config.ts: remove productionBrowserSourceMaps and serverSourceMaps
  (they default to false, restoring pre-v1.68.0 behavior)
- Dockerfile: remove .map copy step and NODE_OPTIONS=--enable-source-maps
- CI: add memory-regression job (<700MB guardrail)

Closes #6123
2026-06-29 15:13:08 +02:00
Thomas ed33748a49 Merge pull request #6118 from darbyjack/docs/warrior 2026-06-29 15:12:57 +02:00
ajnart 57ef0af4a2 fix: make source maps opt-in to restore v1.67.0 memory baseline
Production source maps (--enable-source-maps) caused a ~50% memory increase
(600MB to 1000MB) in v1.68.0. Node.js loads all .map files into memory when
this flag is enabled, and Next.js server chunks generate hundreds of map files.

Changes:
- Guard productionBrowserSourceMaps and serverSourceMaps behind
  HOMARR_ENABLE_SOURCE_MAPS env var (default off)
- Guard .map copy step and NODE_OPTIONS in Dockerfile behind
  ENABLE_SOURCE_MAPS build arg (default false)
- run.sh checks HOMARR_ENABLE_SOURCE_MAPS before setting --enable-source-maps
- Add Docker-based memory regression CI job (<700MB threshold)

Closes #6123
2026-06-29 10:08:52 +02:00
darbyjack f66469d797 feat(docs): addd in documentation for archiveteam warrior 2026-06-28 15:45:52 -05:00
Thomas Camlong 8747d5f93e fix: add id to repository schema and handle missing ids gracefully 2026-06-28 13:55:17 +02:00
Thomas Camlong af35a50fa0 fix: handle missing widgetSecrets table and add default releases repo
- getConfiguredKinds now returns [] if table doesn't exist yet
- Add homarr-labs/homarr as default releases repository (v#.#.#)
2026-06-28 08:47:09 +02:00
Thomas 597b5fbc8d Merge pull request #6097 from homarr-labs/docs/update-helm-docs
Update Helm chart documentation
2026-06-27 13:24:44 +02:00
github-actions[bot] 5412577519 docs(helm): Update Helm chart documentation 2026-06-27 11:22:16 +00:00
ajnart a56bf0f32d style: auto-fix lint and format 2026-06-27 11:04:33 +00:00
Thomas Camlong e687bc8c92 fix: address review - paginate GH releases, GitLab tag_name, docs, test
- Paginate GitHub releases via api.paginate to avoid missing tags beyond page 1
- Use tag_name instead of name for GitLab releases (name is a display label)
- Remove ArchiveTeam Warrior from widget-configured note in docs
- Fix test assertion to use objectContaining for error shape with message
2026-06-27 13:02:58 +02:00
Thomas Camlong 64c7512283 fix: compile versionRegex once and add fetch timeouts
- Compile regex once via compileVersionRegex, reject invalid patterns early
- Add 10s timeout to all provider fetch calls via fetchProvider wrapper
2026-06-27 12:56:48 +02:00
ajnart 398c88f2c6 style: auto-fix lint and format 2026-06-27 09:06:44 +00:00
Thomas Camlong 34d976eeea fix: apply version filter to LinuxServer.io provider
LSIO was bypassing versionRegex unlike all other providers.
2026-06-27 11:01:12 +02:00
Thomas Camlong 757d59854e fix: address PR review - atomic upsert, MCP metadata, stale tokens, save UX
- Wrap setSecret delete+insert in a cross-DB transaction
- Add MCP metadata to all widget secret procedures and register in mcp.ts
- Show configured tokens even when no repos use that provider
- Only clear token input after mutation succeeds, with loading state
2026-06-27 10:58:35 +02:00
Thomas Camlong 2155f13f24 fix: verbose error messages in all release providers
Every error now includes a diagnostic message:
- noMatchingVersion: shows the regex used, how many tags were
  checked, and sample tag names so users can see why matching failed
- noReleasesFound: distinguishes "no releases at all" from "no match"
- invalidIdentifier: shows the problematic identifier string
- unexpected: includes HTTP status codes from provider APIs

getLatestRelease returns { release } | { noMatch: reason } instead
of Release | null, carrying the diagnostic through.
2026-06-27 10:57:14 +02:00
Thomas Camlong d893515b93 fix: catch individual repo fetch failures to prevent batch crashes
Wrap each repository fetch in try/catch so one failure (Redis,
network, etc.) doesn't crash the entire query batch. Failed repos
now return a proper error response instead of causing all repos
to show "No response from provider".
2026-06-27 10:57:14 +02:00
Thomas Camlong 30107e4e76 fix: gracefully degrade when widget_secret table is missing or access denied
The releases widget was crashing because throwIfActionForbiddenAsync
or the widgetSecrets query would throw before DB migrations are run.
Wrap in try/catch so the widget works without tokens in that case.
2026-06-27 10:57:14 +02:00
ajnart 8c51a3769e style: auto-fix lint and format 2026-06-27 10:57:14 +02:00
Thomas Camlong 77884a3179 feat: add per-widget secret storage for release provider authentication
Adds encrypted token storage per release widget so users can authenticate
with GitHub, GitLab, Docker Hub, etc. without needing full integration setup.

- New `widget_secret` table (all 3 DB drivers) keyed by (itemId, kind)
- tRPC router with setSecret/deleteSecret/getConfiguredKinds (board modify access required)
- Releases router injects decrypted tokens into provider requests (board view access required)
- Tokens only used for repos matching widget's stored configuration (prevents proxy abuse)
- Provider auth: GitHub (Octokit auth), GitLab (PRIVATE-TOKEN), Docker Hub (Bearer),
  GHCR (registry token), npm (Bearer), Codeberg (token header)
- UI: "Provider Tokens" section in widget editor with PasswordInput per provider
2026-06-27 10:57:13 +02:00
ajnart d8a7477039 style: auto-fix lint and format 2026-06-27 10:57:13 +02:00
Thomas Camlong 8e1dcecf64 chore: update lockfile 2026-06-27 10:57:13 +02:00
Thomas Camlong c892731479 fix: address PR review findings for widget-only integration refactor
- Fix npm scoped package support (filter time metadata keys, make
  description optional, exclude falsy releases from version selection)
- Fix timetable date formatting (use Intl.DateTimeFormat for stable
  MM/DD/YYYY output) and named zod import
- Guard migration 0000 against unknown provider keys
- Fix migration 0003 to delete integration links by integrationId
- Reset immich album carousel index when photo list changes
- Add bounded limit to timetable router input
- Remove stale misspelled noProviderSeleceted translation key
- Fix provider name visibility in release widget settings
- Update docs: add ArchiveTeam Warrior note, fix typos, correct
  showPhotoInfo default
2026-06-27 10:57:13 +02:00
Thomas Camlong 62baaf7813 fix: update CHANGES.md for ArchiveTeam Warrior restoration, remove typo translation key 2026-06-27 10:57:13 +02:00
Thomas Camlong 8a5ad5861b fix: restore ArchiveTeam Warrior as a proper integration
The widget-only integrations refactor incorrectly removed the
ArchiveTeam Warrior integration entirely. It only needed to be
excluded from auto-seeding (by removing defaultUrl), not deleted.

This restores the integration class, reverts the widget/router/handler
to use integration middleware, and removes archiveTeamWarrior from the
widget-only-integrations migration.
2026-06-27 10:57:13 +02:00
ajnart d18d07d2f1 style: auto-fix lint and format 2026-06-27 10:57:12 +02:00
Thomas Camlong fb348044fb refactor widget-only integrations 2026-06-27 10:57:12 +02:00
Thomas d41e5daa71 Delete .cursor/goal directory 2026-06-25 11:49:23 +02:00
Thomas Camlong 5474181e2e fix: remove unused label.tsx triggering lint error 2026-06-24 16:40:15 +02:00
ajnart cb351f5b69 style: auto-fix lint and format 2026-06-24 14:24:48 +00:00
Thomas Camlong 9301718974 fix: resolve CI lint and typecheck failures
- Add @site/* path alias to docs tsconfig for generated integration files
- Fix label-has-associated-control: use htmlFor/id pairs in SubmitForm
- Suppress no-noninteractive-element-interactions for drag-and-drop zones
- Add aria-labels to search and comment inputs
- Remove unused Label component import
2026-06-24 16:23:34 +02:00
ajnart 8841aac40e style: auto-fix lint and format 2026-06-24 14:14:58 +00:00
Thomas Camlong 568a7a87a5 fix: replace asChild with native anchor elements in DetailPage
The base-ui Button component doesn't support asChild prop.
2026-06-24 16:13:45 +02:00
ajnart e37bd38f34 style: auto-fix lint and format 2026-06-24 13:51:55 +00:00
Thomas Camlong 1cafb5edac chore: regenerate pnpm-lock.yaml after rebase onto dev 2026-06-24 15:50:20 +02:00
Thomas Camlong cc89beca31 fix: workshop UI/UX polish and custom widget table layout
Workshop: fix vote color semantics (primary accent for active state,
contextual score colors), conditional badge positioning, inline delete
confirm, shorter sign-in CTA, comment count on cards, visual anchor
for imageless cards, always-visible gallery nav, footer density polish,
Mantine bg prop for type dot, ghost button back-link.

Custom widgets table: fixed layout with truncation, responsive columns
that hide URL/display on smaller screens to keep name visible.
2026-06-24 15:25:16 +02:00
Thomas Camlong 92d9cdc4b7 fix: unify vote highlight color to destructive (red) for both up and down 2026-06-24 15:24:48 +02:00
Thomas Camlong 20942d47c6 fix: card layout — absolute type badge, truncated title with tooltip
Move the type badge (colored dot + label) to absolute top-left overlay
on the card. Title now truncates to a single line with a native tooltip
on hover showing the full name. Vote controls remain fixed to the right
via CardAction.
2026-06-24 15:24:48 +02:00
Thomas Camlong de8d20a030 refactor: rename marketplace to workshop, add colored type dots
Rename all user-facing "Marketplace" references to "Workshop" including
the route (/workshop), navbar label, page titles, button labels, env vars,
docs, and error messages. The PocketBase view collection stays `marketplace`
as an internal API detail.

Replace icon-based type badges with colored dots (blue = CSS, yellow = widget)
on both the filter bar and submission cards for clearer visual distinction.
2026-06-24 15:24:48 +02:00
Thomas Camlong 1fb7e16d42 feat: add community marketplace for custom widgets and CSS
Adds a PocketBase-backed marketplace where users can share custom widgets
and CSS themes. Includes a Docusaurus storefront with GitHub OAuth login,
submission/voting/comments, and a PocketBase SQL view that computes author
names, vote counts, and comment counts without fragile hooks. The Next.js
app gets a modal browser for one-click install from the marketplace plus
clipboard paste-to-import on the custom widgets pages.
2026-06-24 15:24:48 +02:00
423 changed files with 31771 additions and 16219 deletions

No files matched your search

+8 -1
View File
@@ -59,4 +59,11 @@ TURBO_TELEMETRY_DISABLED=1
# ENABLE_KUBERNETES=true
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true
# UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Community workshop (PocketBase) base URL used by the in-app "Install from Workshop" buttons
# In production this defaults to https://store.homarr.dev — override for local dev:
# NEXT_PUBLIC_WIDGET_STORE_URL=http://localhost:8090
# Docs site workshop URL used for "Open Workshop" links
# In production this defaults to https://homarr.dev/workshop
# NEXT_PUBLIC_WORKSHOP_URL=http://localhost:3003/workshop
+2
View File
@@ -33,6 +33,8 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.69.2
- 1.69.0
- 1.68.0
- 1.67.0
- 1.66.1
+1 -1
View File
@@ -11,7 +11,7 @@ outputs:
runs:
using: "composite"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
+7 -7
View File
@@ -31,7 +31,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -78,7 +78,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -92,7 +92,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -106,7 +106,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
@@ -124,7 +124,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
@@ -153,7 +153,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Upload Crowdin translations
uses: crowdin/github-action@v2
@@ -26,7 +26,7 @@ jobs:
with:
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Get Next Version
@@ -57,7 +57,7 @@ jobs:
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
- uses: actions/checkout@v6
- uses: actions/checkout@v7
if: env.SKIP_RELEASE == 'false'
with:
persist-credentials: false
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.17.0
node-version: 24.18.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
@@ -119,7 +119,7 @@ jobs:
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.git_ref }}
@@ -158,7 +158,7 @@ jobs:
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.git_ref }}
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Build docs
@@ -29,7 +29,7 @@ jobs:
with:
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Get Next Version
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: dev
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: dev
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Validate prebuilt dependencies for amd64
id: validate-amd64
uses: ./.github/actions/prebuilt-debian
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Validate prebuilt dependencies for arm64
id: validate-arm64
uses: ./.github/actions/prebuilt-debian
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- run: |
npx --yes --package renovate -- \
renovate-config-validator --strict .github/renovate.json5
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
token: ${{ steps.obtainToken.outputs.token }}
ref: dev
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
@@ -28,7 +28,7 @@ jobs:
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Setup
+4
View File
@@ -2,6 +2,7 @@
# dependencies
node_modules
.pnpm-store/
.pnp
.pnp.js
.idea/
@@ -70,7 +71,10 @@ apps/nextjs/public/images/background.png
# personal agent config
.opencode
.agents/
skills-lock.json
# docusaurus
apps/docs/.docusaurus
apps/docs/build
.pnpm-store
+1 -1
View File
@@ -1,2 +1,2 @@
[tools]
node = "24.17.0"
node = "24.18.0"
+1 -1
View File
@@ -1 +1 @@
24.17.0
24.18.0
+28
View File
@@ -0,0 +1,28 @@
# Changes — 2026-07-03
## PR #5984 Review And Dev Merge Fixes
- **Why**: Make `feat/widget-store` mergeable with `dev` and address CodeRabbit review feedback before requesting another review.
- **What**: Merged `origin/dev`, resolved `apps/docs/package.json` and `pnpm-lock.yaml`, and fixed workshop vote state handling, 404-only vote recovery, PocketBase filename URL encoding, required widget `$schema` validation, install-button localization, public author email fallback, placeholder product copy, and workshop safety/submission guidance.
- **Files**: `apps/docs/src/components/store/useStore.ts`, `apps/docs/src/lib/pocketbase.ts`, `apps/docs/src/lib/store-schema.ts`, `apps/docs/src/lib/store-schema.spec.ts`, `apps/nextjs/src/components/store-install/install-from-store-buttons.tsx`, `packages/translation/src/lang/en.json`, `apps/store/pb_migrations/1750000002_marketplace_view.js`, `apps/docs/docs/management/store/index.mdx`, `PRODUCT.md`, `pnpm-lock.yaml`, `apps/docs/package.json`.
- **Tests**: `pnpm lint` passed with warnings, `pnpm typecheck` passed, `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed. Full `pnpm test` failed on unrelated timeout/external-dependency tests: Immich icon fetch, Pi-hole container, MySQL migration, and Nextcloud hook.
- **Docs**: Updated workshop management docs with a stronger safety warning and submission guidelines.
- **Risks**: The commit includes a large required `dev` merge, so review should distinguish upstream merge changes from the scoped CodeRabbit fixes.
## PR #5984 Additional CodeRabbit Fixes
- **Why**: Resolve the remaining actionable CodeRabbit comments before requesting Copilot review.
- **What**: Commented out opt-in example env flags, pinned the PocketBase image, ignored store-local env files, documented PocketBase admin bootstrap vars, enabled GitHub OAuth only when credentials are present, indexed `comments.submission`, added reduced-motion handling for submit-step animation, added accessible comment action labels, used created vote records directly on detail pages, tracked copy-failure timers for cleanup, prevented screenshot carousel controls from navigating card links, and normalized the `useStore` utils import alias.
- **Files**: `.env.example`, `apps/store/docker-compose.yml`, `apps/store/.gitignore`, `apps/store/README.md`, `apps/store/pb_migrations/1750000000_init_store.js`, `apps/docs/src/css/custom.css`, `apps/docs/src/components/store/DetailPage.tsx`, `apps/docs/src/components/store/StoreApp.tsx`, `apps/docs/src/components/store/useStore.ts`.
- **Tests**: `pnpm --filter @homarr/docs typecheck`, `pnpm --filter @homarr/docs lint`, `pnpm --filter nextjs typecheck`, and `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed; docs lint still reports pre-existing warnings outside this fix set.
- **Docs**: Updated `apps/store/README.md` with admin bootstrap variables.
- **Risks**: PocketBase image pin assumes `ghcr.io/muchobien/pocketbase:0.26.2` remains the intended runtime version for this new store service.
## PR #5984 Final Review Stability Fix
- **Why**: Address CodeRabbit's remaining outside-diff stability comments before the final push.
- **What**: Updated `useStore` to await PocketBase auth refresh before loading workshop data and to return `null` from `requireUserId` on auth failures, allowing vote lock cleanup to run.
- **Files**: `apps/docs/src/components/store/useStore.ts`, `.opencode/changelog/2026-07-03.md`.
- **Tests**: `pnpm --filter @homarr/docs typecheck`, `pnpm --filter @homarr/docs lint`, and `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed; docs lint still reports pre-existing warnings outside this fix.
- **Docs**: Changelog updated only.
- **Risks**: Low; the change preserves existing auth refresh behavior but sequences initial loading after auth state is known.
+9
View File
@@ -0,0 +1,9 @@
# Changes — 2026-07-06
## Workshop Review Cleanup
- **Why**: Address PR review feedback to use Day.js for date/time logic, split the oversized detail component, and avoid duplicated workshop helpers.
- **What**: Added shared Day.js relative-time formatting, shared download/vote helpers, extracted detail comments and detail UI sections into separate components, and recorded the Day.js/shared-helper convention in `AGENTS.md`.
- **Files**: `apps/docs/src/components/store/DetailPage.tsx`, `apps/docs/src/components/store/StoreApp.tsx`, `apps/docs/src/components/store/useStore.ts`, `apps/docs/src/components/store/DetailComments.tsx`, `apps/docs/src/components/store/DetailSections.tsx`, `apps/docs/src/components/store/format.ts`, `apps/docs/src/components/store/store-utils.ts`, `AGENTS.md`
- **Tests**: `pnpm --filter @homarr/docs typecheck`; `pnpm --filter @homarr/docs lint`; targeted `oxfmt --check` for changed store files.
- **Docs**: Updated `AGENTS.md` with date/time and shared-helper conventions.
- **Risks**: Refactor moves code without intentional behavior changes; UI smoke testing in browser not run.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"oxc.oxc-vscode",
"yoavbls.pretty-ts-errors",
"million.million-lint",
"lokalise.i18n-ally"
+2
View File
@@ -81,3 +81,5 @@ When modifying user-facing code, you MUST also update the corresponding document
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Use `dayjs` for date/time formatting or comparisons; do not hand-roll relative time or date math.
- Extract shared helpers/components when the same logic appears in multiple workshop/store files.
+52
View File
@@ -1,3 +1,55 @@
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
### Bug Fixes
* **layout:** raise AppShellHeader z-index above Indicator dots ([687edb9](https://github.com/homarr-labs/homarr/commit/687edb9226307cb83986097abb24fe7f672e1778)), closes [#6194](https://github.com/homarr-labs/homarr/issues/6194)
## [1.69.1](https://github.com/homarr-labs/homarr/compare/v1.69.0...v1.69.1) (2026-07-04)
### Bug Fixes
* unblock query cache mutations in demo mode and add cache buster ([#6176](https://github.com/homarr-labs/homarr/issues/6176)) ([a382c75](https://github.com/homarr-labs/homarr/commit/a382c75968e73bafff55b5890b1e95f0779b50d3))
## [1.69.0](https://github.com/homarr-labs/homarr/compare/v1.68.1...v1.69.0) (2026-07-03)
### Features
* add per-widget secret storage for release provider authentication ([77884a3](https://github.com/homarr-labs/homarr/commit/77884a31794e12a6f7bc59d96c8f61aa429ca68d))
* **config:** enable pnpm global virtual store for worktree support ([#6098](https://github.com/homarr-labs/homarr/issues/6098)) ([3864727](https://github.com/homarr-labs/homarr/commit/3864727502a64052c4e3838468094b30bf04a133))
* **docker-widget:** add per-column visibility toggle ([#6093](https://github.com/homarr-labs/homarr/issues/6093)) ([0765262](https://github.com/homarr-labs/homarr/commit/076526293d13379004461633d7ba4c2e83bd5ffe))
* **docs:** addd in documentation for archiveteam warrior ([f66469d](https://github.com/homarr-labs/homarr/commit/f66469d79797a2da214588f4e8253b03633c59a5))
* simplify caching — TanStack Query as single source of truth ([#6075](https://github.com/homarr-labs/homarr/issues/6075)) ([f19033e](https://github.com/homarr-labs/homarr/commit/f19033e0d53d248aed678737f3d04d5d8c7815b5))
* **widgets:** right-click context menu with auto-generated toggle actions ([#6126](https://github.com/homarr-labs/homarr/issues/6126)) ([7e58858](https://github.com/homarr-labs/homarr/commit/7e58858353ae866efd657270d4bb16d2c7d3e551))
* **widgets:** show calendar events on hover with pin on click ([#2637](https://github.com/homarr-labs/homarr/issues/2637)) ([#6081](https://github.com/homarr-labs/homarr/issues/6081)) ([db3d588](https://github.com/homarr-labs/homarr/commit/db3d58821e4de8d46b110125d0fa36919765248f))
### Bug Fixes
* add id to repository schema and handle missing ids gracefully ([8747d5f](https://github.com/homarr-labs/homarr/commit/8747d5f93ee9a295aad3c7035e8c6c7080bf5ee6))
* address PR review - atomic upsert, MCP metadata, stale tokens, save UX ([757d598](https://github.com/homarr-labs/homarr/commit/757d59854e5ec82d91c053b8bc3d7eaf2e70efc4))
* address PR review findings for widget-only integration refactor ([c892731](https://github.com/homarr-labs/homarr/commit/c892731479b47fab6218bd1f52fa6eccbdaa371e))
* address review - paginate GH releases, GitLab tag_name, docs, test ([e687bc8](https://github.com/homarr-labs/homarr/commit/e687bc8c9277797acaa164a09c74b3808a2cb346))
* apply version filter to LinuxServer.io provider ([34d976e](https://github.com/homarr-labs/homarr/commit/34d976eeea09e396334ce8fce5c492c07e7dd32b))
* catch individual repo fetch failures to prevent batch crashes ([d893515](https://github.com/homarr-labs/homarr/commit/d893515b932608ea08c9c6e90b83f340e40f6f8e))
* compile versionRegex once and add fetch timeouts ([64c7512](https://github.com/homarr-labs/homarr/commit/64c75122833981a49e9c8bf1789d501faff6d62d))
* **deps:** update dependency postcss to ^8.5.16 ([#6169](https://github.com/homarr-labs/homarr/issues/6169)) ([9252fdc](https://github.com/homarr-labs/homarr/commit/9252fdc13971bd982224a31d7be577112f99ec45))
* dns-hole widgets showing stale disconnected state ([#6096](https://github.com/homarr-labs/homarr/issues/6096)) ([2443f1c](https://github.com/homarr-labs/homarr/commit/2443f1cd3ff6765a91783d60d42aa03d9ba63fcd))
* gracefully degrade when widget_secret table is missing or access denied ([30107e4](https://github.com/homarr-labs/homarr/commit/30107e4e76a454ff7402de9a8bc2e0dd6f422aa8))
* handle missing widgetSecrets table and add default releases repo ([af35a50](https://github.com/homarr-labs/homarr/commit/af35a50fa05fee2f0118e98727eb108b0200f762))
* **immich:** v3 API compatibility — use searchAssets for album assets, drop deviceAssetId ([#5977](https://github.com/homarr-labs/homarr/issues/5977)) ([a5ec880](https://github.com/homarr-labs/homarr/commit/a5ec880eec276e0814d2ab3ed7f9bc0b804d0465))
* quick wins — calendar, about, glances, admin UX, releases, iframe, notes ([#6116](https://github.com/homarr-labs/homarr/issues/6116)) ([f9da71a](https://github.com/homarr-labs/homarr/commit/f9da71a19bdfa589aafe26b164d0e0782208ac0a))
* restore ArchiveTeam Warrior as a proper integration ([8a5ad58](https://github.com/homarr-labs/homarr/commit/8a5ad5861ba4c2d0978f1fa3f6115a1c5974f8a2))
* **timetable:** restore station search by removing disabled query guard ([d670b69](https://github.com/homarr-labs/homarr/commit/d670b6939672932c1a3d17ab9913901491cb46e3))
* update CHANGES.md for ArchiveTeam Warrior restoration, remove typo translation key ([62baaf7](https://github.com/homarr-labs/homarr/commit/62baaf78130c4b74e899a948cee17097d8c02593))
* verbose error messages in all release providers ([2155f13](https://github.com/homarr-labs/homarr/commit/2155f13f245f601b99458e227b6e058fbfa44cd9))
* **widgets:** make downloads widget table background transparent ([#6025](https://github.com/homarr-labs/homarr/issues/6025)) ([73bed51](https://github.com/homarr-labs/homarr/commit/73bed51df895250f415efd7a87e721e403b3fd99))
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
### Bug Fixes
* make source maps opt-in to restore v1.67.0 memory baseline ([57ef0af](https://github.com/homarr-labs/homarr/commit/57ef0af4a252ea39727caeba7e13587dabc6254e)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
* revert production source maps to restore v1.67.0 memory baseline ([60a69e3](https://github.com/homarr-labs/homarr/commit/60a69e31efc7168b8f4309daf75954d7730d883b)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26)
### Features
+96
View File
@@ -0,0 +1,96 @@
# Changes
## Goal
Remove widget-only services from Homarr's global Integration system.
The affected services are release providers and Search.ch. They are only used by specific widgets, so keeping them as global integrations added unnecessary setup, UI, database rows, docs pages, and integration package code.
ArchiveTeam Warrior remains a proper integration but is no longer auto-seeded on first install.
## Why
These services do not need shared integration state.
- Release providers are selected per Releases widget repository.
- Search.ch is only used by the Timetable widget.
Moving them into widget options makes configuration simpler and removes dead-weight integration code from `packages/integrations`.
## What Changed
### Release Providers
- Added release provider definitions in `packages/definitions/src/release-provider.ts`.
- Removed release providers from `IntegrationKind`.
- Moved release-fetching code out of `packages/integrations` and into `packages/request-handler/src/release-providers.ts`.
- Updated the Releases widget to store provider configuration directly in widget options:
- `provider`
- `identifier`
- `versionFilter`
- `providerUrl`
- Updated the Releases widget editor to use a provider select instead of an integration select.
- Updated Docker import mapping so `ghcr.io` maps to `gitHubContainerRegistry`.
- GitHub Container Registry now uses the public OCI registry API and accepts full image references such as `ghcr.io/homarr-labs/homarr:latest`.
### Timetable / Search.ch
- Removed Search.ch as an integration.
- Added a Timetable widget `baseUrl` option, defaulting to `https://search.ch`.
- Moved Search.ch request/parsing logic into `packages/request-handler/src/timetable.ts`.
- Updated the Timetable API to receive `baseUrl` directly from widget options.
### ArchiveTeam Warrior
- Kept as a proper integration (not removed).
- Removed `defaultUrl` from the integration definition to prevent auto-seeding on first install.
### Integration Cleanup
- Removed creators, exports, docs references, and implementation files for the deleted integrations.
- Removed unused dependencies from `packages/integrations/package.json`.
### Database Migration
- Added `packages/db/migrations/custom/0003_remove_widget_only_integrations.ts`.
- The migration moves old integration-linked widget config into widget options, then deletes the old widget-only integration rows.
- Updated the earlier release widget migration to use the new `provider` field instead of `providerIntegrationId`.
### Docs
- Updated Releases widget docs to describe providers instead of integrations.
- Updated Timetable docs to remove Search.ch integration setup.
- Removed obsolete integration docs pages for the deleted integration kinds.
- Updated docs sitemap and integration docs mappings.
### Translations
- Added English labels for the new widget options:
- `widget.timetable.option.baseUrl.label`
- `widget.releases.option.repositories.providerUrl.label`
## How It Works Now
Release provider configuration lives on each Releases widget repository entry. The request handler resolves provider metadata and fetches releases directly from the provider-specific endpoint. GHCR uses anonymous OCI registry access for public packages.
Timetable passes its widget URL option directly to its API router and request handler. No global integration lookup is needed.
ArchiveTeam Warrior continues to use the standard integration system, but users must manually add the integration (it is not auto-created on install).
## Deliberate Non-Goals
- No provider secret/token support was added.
- No new secret storage model was introduced.
- No compatibility layer was kept in `packages/integrations`.
Widget options are client-visible, so storing provider credentials there would be wrong. If authenticated provider requests are needed later, they need a separate secret-storage design.
## Verification So Far
- Focused typechecks passed for affected packages.
- Full `pnpm turbo typecheck` passed.
- Full `pnpm lint` passed with existing warnings.
- Focused release provider normalization tests passed.
- Widget translation test passed.
- Live GHCR smoke checks passed for `ghcr.io/muchobien/pocketbase:latest` and `ghcr.io/homarr-labs/homarr:latest`.
- `pnpm test packages/integrations/test/pi-hole.spec.ts` passed earlier, but failed on rebased reruns because Pi-hole reported `domainsBeingBlocked` as `-2`/`0` instead of `> 1`.
+185 -4
View File
@@ -1,12 +1,193 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers
# Specific features / integrations
# Glueten
apps/docs/docs/integrations/glueten @Lok3sh-kumar
# Specific features / integrations
# These owners come from the PR that introduced each integration implementation.
# Specific rules override the global fallback because CODEOWNERS uses the last
# matching pattern; do not include @homarr-labs/maintainers here unless they
# should also be requested for that integration.
# AdGuard Home - introduced in https://github.com/homarr-labs/homarr/pull/929 by @hillaliy
apps/docs/docs/integrations/adguard-home @hillaliy
packages/integrations/src/adguard-home @hillaliy
# Anchor - introduced in https://github.com/homarr-labs/homarr/pull/5040 by @ohmzi
apps/docs/docs/integrations/anchor @ohmzi
packages/integrations/src/anchor @ohmzi
# ArchiveTeam Warrior - introduced in https://github.com/homarr-labs/homarr/pull/5784 by @darbyjack
apps/docs/docs/integrations/archive-team-warrior @darbyjack
packages/integrations/src/archive-team-warrior @darbyjack
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/audiobookshelf @ajnart
packages/integrations/src/audiobookshelf @ajnart
# Beszel - introduced in https://github.com/homarr-labs/homarr/pull/5900 by @ajnart
apps/docs/docs/integrations/beszel @ajnart
packages/integrations/src/beszel @ajnart
# Codeberg - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/codeberg @Aandree5
packages/integrations/src/codeberg @Aandree5
# Coolify - introduced in https://github.com/homarr-labs/homarr/pull/4789 by @Rhiz3K
apps/docs/docs/integrations/coolify @Rhiz3K
packages/integrations/src/coolify @Rhiz3K
# Dash. - introduced in https://github.com/homarr-labs/homarr/pull/1541 by @manuel-rw
apps/docs/docs/integrations/dash-dot @manuel-rw
packages/integrations/src/dashdot @manuel-rw
# Docker Hub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/docker-hub @Aandree5
packages/integrations/src/docker-hub @Aandree5
# Emby - introduced in https://github.com/homarr-labs/homarr/pull/2466 by @Meierschlumpf
apps/docs/docs/integrations/emby @Meierschlumpf
packages/integrations/src/emby @Meierschlumpf
# GitHub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/github @Aandree5
packages/integrations/src/github @Aandree5
# GitHub Container Registry - introduced in https://github.com/homarr-labs/homarr/pull/3727 by @Aandree5
apps/docs/docs/integrations/github-containerregistry @Aandree5
packages/integrations/src/github-container-registry @Aandree5
# GitLab - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/gitlab @Aandree5
packages/integrations/src/gitlab @Aandree5
# Glances - introduced in https://github.com/homarr-labs/homarr/pull/5102 by @manuel-rw
apps/docs/docs/integrations/glances @manuel-rw
packages/integrations/src/glances @manuel-rw
# Gluetun - introduced in https://github.com/homarr-labs/homarr/pull/5812 by @Lok3sh-kumar
apps/docs/docs/integrations/gluetun @Lok3sh-kumar
packages/integrations/src/gluetun @Lok3sh-kumar
# Exempt Renovate‑managed files (no owners)
# Gotify - introduced in https://github.com/homarr-labs/homarr/pull/5847 by @Nerdy-Technician
apps/docs/docs/integrations/gotify @Nerdy-Technician
packages/integrations/src/gotify @Nerdy-Technician
# Home Assistant - introduced in https://github.com/homarr-labs/homarr/pull/578 by @manuel-rw
apps/docs/docs/integrations/home-assistant @manuel-rw
packages/integrations/src/homeassistant @manuel-rw
# ICal - introduced in https://github.com/homarr-labs/homarr/pull/3980 by @NicolasNewman
apps/docs/docs/integrations/ical @NicolasNewman
packages/integrations/src/ical @NicolasNewman
# Immich - introduced in https://github.com/homarr-labs/homarr/pull/5165 by @manuel-rw
apps/docs/docs/integrations/immich @manuel-rw
packages/integrations/src/immich @manuel-rw
# Jellyfin - introduced in https://github.com/homarr-labs/homarr/pull/672 by @manuel-rw
apps/docs/docs/integrations/jellyfin @manuel-rw
packages/integrations/src/jellyfin @manuel-rw
# Jellyseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
apps/docs/docs/integrations/jellyseerr @manuel-rw
packages/integrations/src/jellyseerr @manuel-rw
# LinuxServer.io - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
apps/docs/docs/integrations/linux-server-io @Aandree5
packages/integrations/src/linuxserverio @Aandree5
# Navidrome - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/navidrome @ajnart
packages/integrations/src/navidrome @ajnart
# Nextcloud - introduced in https://github.com/homarr-labs/homarr/pull/2501 by @manuel-rw
apps/docs/docs/integrations/nextcloud @manuel-rw
packages/integrations/src/nextcloud @manuel-rw
# npm - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/npm @Aandree5
packages/integrations/src/npm @Aandree5
# ntfy - introduced in https://github.com/homarr-labs/homarr/pull/2900 by @itzTheMeow
apps/docs/docs/integrations/ntfy @ajnart @manuel-rw
packages/integrations/src/ntfy @ajnart @manuel-rw
# OpenMediaVault - introduced in https://github.com/homarr-labs/homarr/pull/1142 by @hillaliy
apps/docs/docs/integrations/open-media-vault @hillaliy
packages/integrations/src/openmediavault @hillaliy
# OPNsense - introduced in https://github.com/homarr-labs/homarr/pull/3424 by @Oupsman
apps/docs/docs/integrations/opnsense @Oupsman
packages/integrations/src/opnsense @Oupsman
# Overseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
apps/docs/docs/integrations/overseerr @manuel-rw
packages/integrations/src/overseerr @manuel-rw
# Paperless-ngx - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/paperless-ngx @ajnart
packages/integrations/src/paperless-ngx @ajnart
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
apps/docs/docs/integrations/peanut @afonsojramos
packages/integrations/src/peanut @afonsojramos
# Pi-hole - introduced in https://github.com/homarr-labs/homarr/pull/521 by @Meierschlumpf
apps/docs/docs/integrations/pi-hole @Meierschlumpf
packages/integrations/src/pi-hole @Meierschlumpf
# Plex - introduced in https://github.com/homarr-labs/homarr/pull/1342 by @hillaliy
apps/docs/docs/integrations/plex @hillaliy
packages/integrations/src/plex @hillaliy
# Prowlarr - introduced in https://github.com/homarr-labs/homarr/pull/965 by @hillaliy
apps/docs/docs/integrations/prowlarr @hillaliy
packages/integrations/src/prowlarr @hillaliy
# Proxmox - introduced in https://github.com/homarr-labs/homarr/pull/1969 by @Meierschlumpf
apps/docs/docs/integrations/proxmox @Meierschlumpf
packages/integrations/src/proxmox @Meierschlumpf
# Quay - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
apps/docs/docs/integrations/quay @Aandree5
packages/integrations/src/quay @Aandree5
# search.ch - introduced in https://github.com/homarr-labs/homarr/pull/5180 by @Meierschlumpf
apps/docs/docs/integrations/search-ch @Meierschlumpf
packages/integrations/src/search-ch @Meierschlumpf
# Speedtest Tracker - introduced in https://github.com/homarr-labs/homarr/pull/5363 by @kylegrieder
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
packages/integrations/src/speedtest-tracker @kylegrieder
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
apps/docs/docs/integrations/technitium-dns @lopsch
packages/integrations/src/technitium @lopsch
# Tracearr - introduced in https://github.com/homarr-labs/homarr/pull/5060 by @StrandedTurtle
apps/docs/docs/integrations/tracearr @StrandedTurtle
packages/integrations/src/tracearr @StrandedTurtle
# TrueNAS - introduced in https://github.com/homarr-labs/homarr/pull/3745 by @manuel-rw
apps/docs/docs/integrations/truenas @manuel-rw @afonsojramos
packages/integrations/src/truenas @manuel-rw @afonsojramos
# Umami - introduced in https://github.com/homarr-labs/homarr/pull/5362 by @febrile42
apps/docs/docs/integrations/umami @febrile42
packages/integrations/src/umami @febrile42
# UniFi Controller - introduced in https://github.com/homarr-labs/homarr/pull/2236 by @pitschi
apps/docs/docs/integrations/unifi-controller @pitschi
packages/integrations/src/unifi-controller @pitschi
# Unraid - introduced in https://github.com/homarr-labs/homarr/pull/4439 by @manuel-rw
apps/docs/docs/integrations/unraid @manuel-rw
packages/integrations/src/unraid @manuel-rw
# Uptime Kuma - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# Exempt Renovate-managed files (no owners)
package.json
**/package.json
package-lock.json
+2 -15
View File
@@ -1,4 +1,4 @@
FROM node:24.17.0-alpine AS base
FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
# See https://github.com/homarr-labs/homarr/issues/5891
RUN cd apps/nextjs/.next/server && \
find . -name '*.map' | while IFS= read -r map; do \
dest="../standalone/apps/nextjs/.next/server/$map"; \
mkdir -p "$(dirname "$dest")"; \
cp "$map" "$dest"; \
done
FROM base AS runner
WORKDIR /app
@@ -53,6 +42,7 @@ RUN mkdir -p /var/cache/nginx && \
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
@@ -75,9 +65,6 @@ ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
ENV NODE_OPTIONS='--enable-source-maps'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
+35
View File
@@ -0,0 +1,35 @@
# Product
## Register
GitHub OAuth is used for workshop accounts so community members can submit, vote on, comment on, and report shared widgets or CSS themes without creating a separate Homarr-specific identity.
## Users
Self-hosting enthusiasts managing homelab services. Technical users comfortable with Docker, APIs, and configuration files. They use Homarr as a personal dashboard to monitor and control 50+ services (media servers, DNS, NAS, download clients). Context: desktop browser, often on a local network, task-focused.
## Product Purpose
Homarr is a self-hosted dashboard for homelab management. It aggregates service status, media libraries, system stats, and administrative controls into a single drag-and-drop interface with 39+ widgets. The marketplace extension lets users share and install custom widgets and CSS themes created by the community.
## Brand Personality
Practical, capable, community-driven.
## Anti-references
- Generic SaaS landing pages with gradient hero sections and metric grids
- Overly corporate enterprise dashboards (ServiceNow, Salesforce)
- Cluttered "hacker" UIs with no visual hierarchy
## Design Principles
1. **Tool disappears into the task.** The interface should feel invisible; users came to manage their homelab, not admire the dashboard.
2. **Density when it earns it.** Show useful information compactly; don't spread thin content across oversized cards.
3. **Community first.** Sharing, voting, and installing should feel effortless and trustworthy.
4. **Consistency across surfaces.** The marketplace in the docs app should feel like it belongs to the same product as the main Next.js app.
5. **Respect the user's time.** Fast loads, instant feedback, no choreographed animations.
## Accessibility & Inclusion
WCAG 2.1 AA. Support for reduced motion. Sufficient contrast ratios (4.5:1 body text, 3:1 large text). Keyboard navigable.
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/css/custom.css",
"baseColor": "mist",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "tabler",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
@@ -28,6 +28,7 @@ Read our [glossary](/docs/getting-started/glossary) if you are new to Homarr.
Congratulations on installing Homarr. Now it's time to get yourself familiar with the concepts of Homarr and how to use it.
## Onboarding
The onboard will show the first time you start Homarr.
This process will help you with your first steps and adding some initial data, such as the administrator account.
@@ -45,50 +46,68 @@ You can either import a previous dashboard using the ZIP archive or start from s
You may refresh anytime and continue from a different device - the onboarding is saved immediately on the server for your convenience.
### Starting from scratch
Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form.
If credentials provider was selected, you'll be prompted to create an admin user for Homarr.
This user cannot be deleted and has full control over Homarr and all of it's boards.
You must choose an username and password (both can later be changed).
We enforce strict password requirements, when you enter your password, to protect your instance if you accidentially
expose it to the Internet.
<img src={imageStartFromScratchStepAdminUser} alt={"Screenshot form to create admin user"} width={300} />
Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form.
If the credentials provider was selected, you'll be prompted to create an admin user for Homarr.
This user cannot be deleted and has full control over Homarr and all of its boards.
You must choose a username and password (both can later be changed).
We enforce strict password requirements when you enter your password to protect your instance if you accidentally
expose it to the internet.
<img
src={imageStartFromScratchStepAdminUser}
alt={"Screenshot form to create admin user"}
width={300}
/>
If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group.
This group will be used to synchronize users from your provider to Homarr.
When a user is logged in through the provider the groups of him that match groups with any in Homarr will be added to the user.
The name of the group has to match exactly the name of the group in the provider.
This group will be used to synchronize users from your provider to Homarr.
When a user logs in through the provider, any of their groups that match groups in Homarr will be assigned to the user.
The name of the group must match exactly the name of the group in the provider.
<img src={imageStartFromScratchStepExternalAdminGroup} alt={"Screenshot form to create external admin group"} width={300} />
<img
src={imageStartFromScratchStepExternalAdminGroup}
alt={"Screenshot form to create external admin group"}
width={300}
/>
Next, you can configure some basic settings for Homarr. We recommend to leave all settings at default for most users.
Here you can also disable analytics. Please note that our analytics are completly anonymous and is fully compliant with GDPR and PECR.
<img src={imageStartFromScratchStepServerSettings} alt={"Screenshot of analytics and indexing settings in the onboarding"} width={300} />
<img
src={imageStartFromScratchStepServerSettings}
alt={"Screenshot of analytics and indexing settings in the onboarding"}
width={300}
/>
### Setting up integrations
After configuring server settings, the onboarding guides you through setting up your integrations.
#### Docker service discovery
If Docker is available (socket mounted), Homarr automatically detects running containers and matches them to known integration types.
Matching integrations are pre-selected in the integration list, saving you from manually picking each one.
#### URL templating
You can define a base URL template to auto-fill integration URLs in bulk.
Two modes are supported:
- **Subdomain mode** - e.g. `{app}.example.com` where `{app}` is replaced with the integration name
- **Port mode** - e.g. `192.168.1.1:{port}` where `{port}` is replaced with the integration's default port
#### API key helper links
For integrations that require API keys (such as Sonarr, Radarr, SABnzbd, or Seerr), a helper link opens the relevant settings page in the target application so you can quickly copy your key.
#### Creating apps from Docker containers
You can optionally auto-create bookmark apps for all discovered Docker containers, making them immediately available on your board.
:::note
Some niche and registry integrations (GitHub, npm, Docker Hub, GitLab, Codeberg, LinuxServer.io, GHCR, Quay, Search.ch) are hidden from the onboarding selection grid but remain available on the manage integrations page.
Release providers and Search.ch are configured directly in their widgets instead of the integrations page.
:::
@@ -97,30 +116,41 @@ The onboarding can no longer be started again.
From here you can click on one of the provided links to continue your journey.
We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board).
<img src={imageStartFromScratchStepFinish} alt={"Screenshot of the finish onboarding process with links"} width={300} />
<img
src={imageStartFromScratchStepFinish}
alt={"Screenshot of the finish onboarding process with links"}
width={300}
/>
### Importing a ZIP from version before 1.0.0
This is recommended when you used Homarr before and want to re-import your data.
It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0).
<img src={manageExportOldHomarr} alt="Export from old Homarr" style={{ maxWidth: 500 }} />
<img
src={manageExportOldHomarr}
alt="Export from old Homarr"
style={{ maxWidth: 500 }}
/>
**How to export and import data**
1. Navigate to your old Homarr, open your management pages, open the tools submenu and open ``Migrate to 1.0``.
1. Navigate to your old Homarr, open your management pages, open the tools submenu and open `Migrate to 1.0`.
2. Check the contents that you want to export. We recommend you to export all data.
3. Click on the export data button
4. Save the ZIP file, that is being downloaded, on your device. If you selected "integrations" or "users", you also get an encryption / decryption key.
Copy it and do not loose it!
5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose ``Import from Homarr before 1.0``
Copy it and do not loose it!
5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose `Import from Homarr before 1.0`
6. Drag and drop your previously downloaded ZIP archive into the file upload: <br/><img src={manageImportOldHomarrDropzone} style={{ maxWidth: 500 }} />
7. Next, choose the data that you want to import and how to import it. Since screen sizes were removed in 1.0, we create a new board for every screen size by default. We recommend you to only keep the best layout and adjust it later in 1.0.0 - but you can also import all of them and pick later.<br/><img src={manageImportOldHomarrChooseData} style={{ maxWidth: 500 }} />
8. If you previously exported integrations or users, you will be asked to decrypt the data safely using your obtained encryption token. Paste into the modal and confirm your action.<br/><img src={manageImportDecryptionToken} style={{ maxWidth: 500 }} />
9. Next, you can [follow the same setup as when starting from scratch](#starting-from-scratch). After you completed the onboarding, all of your data will be migrated and available.
## Creating your first board
In Homarr, you organize your apps and widgets on boards.
Boards can be organized using the interactive drag and drop.
Let's create your first board by navigating here: ``Manage`` > ``Boards`` > ``New board``:
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
![](./img/manage-create-board-button.png)
A board must always have a name and can either be public or private.
@@ -133,12 +163,13 @@ Congratulations - you've just created your first board. You can see boards, that
![](./img/manage-view-board.png)
## Add your first app to Homarr
To add links to your dashboard, you must first add them as an "app".
Navigate to this page to add an app:
![](./img/manage-navigate-apps.png)
Next, click on the ``New app`` button. You'll need to provide a name and an icon to create an app.
Next, click on the `New app` button. You'll need to provide a name and an icon to create an app.
The name must be at least one character long but you can provide any image URL for the icon.
Homarr ships with over 11K icons - so you can just search by entering a search text instead.
@@ -149,8 +180,9 @@ After you created the app, you'll see in on the apps page:
![](./img/manage-created-app.png)
## Arrange and organize your board
Let's add your freshly created app, from the previous step, to the board.
To do this, edit your previously created board by clicking on the big ``Open board`` button:
To do this, edit your previously created board by clicking on the big `Open board` button:
![](./img/manage-view-board.png)
@@ -165,23 +197,38 @@ Here you have the following options in the header:
When entering the edit mode, you'll get some additional buttons to work with:
<img src={manageBoardHeaderEditMode} alt={"Screenshot of buttons in the edit mode"} width={200} />
<img
src={manageBoardHeaderEditMode}
alt={"Screenshot of buttons in the edit mode"}
width={200}
/>
- **Add item**: Click this button to add new elements to your page.
- **Exit edit mode**: Click this btuton to save your changes and exit edit mode.
To continue, click the option "New Item":
<img src={manageBoardHeaderAddItem} alt={"Screenshot of the add item menu"} width={200} />
<img
src={manageBoardHeaderAddItem}
alt={"Screenshot of the add item menu"}
width={200}
/>
Next, you can choose what item you'd like to add.
We will discuss the other options in the following sections - for now choose "App" to continue.
<img src={manageBoardHeaderChooseItem} alt={"Screenshot of choosing the app item in the modal"} width={300} />
<img
src={manageBoardHeaderChooseItem}
alt={"Screenshot of choosing the app item in the modal"}
width={300}
/>
As soon as you chose the app, the modal will close and you'll get the following item on your board:
<img src={manageBoardNoApp} alt="Screenshot of the No app element on the board" />
<img
src={manageBoardNoApp}
alt="Screenshot of the No app element on the board"
/>
This is expected, because you haven't told Homarr what app it should show in this item.
To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu.
@@ -228,17 +275,18 @@ Be sure to save your changes by exiting edit mode!
![](./img/exit-edit-mode.png)
## Adding your first integration
Now it's time to integrate some basic data into your board.
To do this, we first need to create an integration.
An integration is a connection between Homarr and a third party application - usually something that you run and deploy yourself.
Navigate to ``Manage`` > ``Integrations`` (for example via your user menu).
Navigate to `Manage` > `Integrations` (for example via your user menu).
As an example, we are going to use the public demo instance of **Dash.** - a popular application to monitor CPU / hardware usage.
Choose the **Dash.** integration from the list of available integrations:
![](./img/choose-integration.gif)
Next, choose a name (or leave it at default), enter ``https://dash.mauz.dev/`` as the URL in the form and click on ``Test connection and create``.
Next, choose a name (or leave it at default), enter `https://dash.mauz.dev/` as the URL in the form and click on `Test connection and create`.
Pressing the save button will perform a test request to the integration, to confirm that the networking setup is working as expected.
Sometimes invalid configurations, firewalls and other mistakes can lead to connectivity problems - [follow this guide here](/docs/management/integrations/#troubleshooting-connection) if you experience issues.
@@ -254,13 +302,14 @@ the target application or your router.
:::
## Embed integration data using widgets
Let's add your integration, that you have configured in the section above, as a widget to your board.
First, navigate back to your board that you want to add it to.
Next, enter edit mode again and add another item to your board:
![](./img/manage-board-header-add-item-menu.png)
Instead of choosing an app, let's choose ``System Health Monitoring`` this time.
Instead of choosing an app, let's choose `System Health Monitoring` this time.
Click on "Add to board":
![](./img/manage-board-header-choose-system-health.png)
@@ -290,10 +339,12 @@ As with every change, don't forget to save your changes using the exit edit mode
![](./img/exit-edit-mode.png)
## Grouping your items
As the last step, let's organize your freshly created items using two different options.
You can choose either or both of them on your boards.
### Using dynamic sections
Dynamic sections are a simple, resizable visual group to group items.
![](./img/create-dynamic-section.png)
@@ -303,6 +354,7 @@ After adding a dynamic section, you can simply drag and drop items into the sect
![](./img/dynamic-section.gif)
### Using categories
Categories are more advanced and complicated than dynamic sections.
To create a category, open the add item modal and choose "new category":
@@ -316,7 +368,6 @@ You can drag and drop items in to this category in the edit mode.
Categories will have a big space before and after them in the edit mode to
enable you to drag and drop items outside the category.
You can also collapse or open categories in edit mode to facilitate your editing process.
Use the category menu to open every app in that category in new tabs, or use the board header action to open every app on the current board layout in new tabs.
:::note
@@ -330,22 +381,27 @@ You cannot reduce their horizontal width.
After completing the onboarding, Homarr provides interactive guided tours to help you discover key features.
### Management tour
When you first visit the management area (`/manage`), Homarr walks you through the main pages: boards, apps, integrations, and users (admin only).
Each page highlights the list view and the create action so you know where to find and add things.
### Board tour
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
### Tour controls
- Press **Enter** (or click **Next**) to advance through tour steps
- Tours auto-start on first visit and do not repeat once completed
- Tours are skipped on mobile screens (below `sm` breakpoint)
- You can **reset tours** from the user settings page under **Manage > Users > [Your user] > General**
## Next steps
You're done with the basic setup 🔥. Now it's time to organize and customize even more.
We highly recommend you to experiment with different options, styles and configurations.
See these curated pages for your next steps:
- Create new apps using the [apps management](/docs/management/apps/)
- Customize your board or add more boards using the [boards management](/docs/management/boards/)
- Integrate software that you use using the [integrations management](/docs/management/integrations/)
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.19.0](https://img.shields.io/badge/Version-8.19.0-informational?style=flat)
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.60.0](https://img.shields.io/badge/AppVersion-v1.60.0-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -383,6 +383,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
@@ -395,7 +396,6 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) |
| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field |
| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login |
| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
@@ -0,0 +1,35 @@
---
title: "ArchiveTeam Warrior"
description: "ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { archiveTeamWarriorIntegration } from ".";
<IntegrationHeader integration={archiveTeamWarriorIntegration} categories={["archiving"]} />
The ArchiveTeam Warrior integration lets Homarr connect to your ArchiveTeam Warrior instance so you can manage and monitor your archiving tasks directly from your dashboard.
### Adding the integration
<AddingIntegration />
### Secrets
ArchiveTeam Warrior does not require authentication by default. If your instance is protected with basic authentication, you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [],
},
]}
/>
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const archiveTeamWarriorIntegration: IntegrationDefinition = {
name: "ArchiveTeam Warrior",
description:
"ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive.",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
path: "../../integrations/archiveteam-warrior",
};
@@ -1,37 +0,0 @@
---
title: 'Codeberg'
description: "Codeberg is a non-profit, community-driven platform for hosting and collaborating on software projects."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { codebergIntegration } from '.';
import { releasesWidget } from '@site/docs/widgets/releases';
<IntegrationHeader
integration={codebergIntegration}
categories={['Version Control', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ['personalAccessToken'],
steps: []
}]} />
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const codebergIntegration: IntegrationDefinition = {
name: "Codeberg",
description:
"Codeberg is a non-profit, community-driven platform for hosting and collaborating on software projects.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/codeberg.svg",
path: "../../integrations/codeberg",
};
@@ -1,37 +0,0 @@
---
title: 'Docker Hub'
description: "Docker Hub is a cloud-based registry service for sharing and managing Docker images."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { dockerHubIntegration } from '.';
import { releasesWidget } from '@site/docs/widgets/releases';
<IntegrationHeader
integration={dockerHubIntegration}
categories={['Container Management', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ['username', 'personalAccessToken'],
steps: []
}]} />
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const dockerHubIntegration: IntegrationDefinition = {
name: "Docker Hub",
description: "Docker Hub is a cloud-based registry service for sharing and managing Docker images.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg",
path: "../../integrations/docker-hub",
};
@@ -1,42 +0,0 @@
---
title: 'GitHub Container Registry'
description: "GitHub Container Registry is a platform for hosting and managing packages."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { gitHubContainerRegistryIntegration } from '.';
import { releasesWidget } from '@site/docs/widgets/releases';
<IntegrationHeader
integration={gitHubContainerRegistryIntegration}
categories={['Package Management', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ["personalAccessToken"],
steps: []
}, {
tabLabel: "GitHub App",
credentials: ['githubAppId', 'githubInstallationId', 'privateKey'],
steps: ['Create GitHub App', 'Create a private key for it', 'Install it to your organization / user']
}]} />
@@ -1,11 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const gitHubContainerRegistryIntegration: IntegrationDefinition = {
name: "GitHub Container Registry",
description: "GitHub Container Registry is a platform for hosting and managing packages.",
iconUrl: {
light: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github.svg",
dark: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github-light.svg",
},
path: "../../integrations/github-packages",
};
@@ -1,41 +0,0 @@
---
title: 'GitHub'
description: "GitHub is a web-based platform for version control and collaboration."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { githubIntegration } from '.';
import { releasesWidget } from '@site/docs/widgets/releases';
<IntegrationHeader
integration={githubIntegration}
categories={['Version Control', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ['personalAccessToken'],
steps: []
}, {
tabLabel: "GitHub App",
credentials: ['githubAppId', 'githubInstallationId', 'privateKey'],
steps: ['Create GitHub App', 'Create a private key for it', 'Install it to your organization / user']
}]} />
@@ -1,11 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const githubIntegration: IntegrationDefinition = {
name: "GitHub",
description: "GitHub is a web-based platform for version control and collaboration.",
iconUrl: {
light: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github.svg",
dark: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github-light.svg",
},
path: "../../integrations/github",
};
@@ -1,37 +0,0 @@
---
title: 'GitLab'
description: "GitLab is a web-based DevOps lifecycle tool that provides a Git repository manager."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { gitlabIntegration } from '.';
import { releasesWidget } from '@site/docs/widgets/releases';
<IntegrationHeader
integration={gitlabIntegration}
categories={['Version Control', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ['personalAccessToken'],
steps: []
}]} />
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const gitlabIntegration: IntegrationDefinition = {
name: "GitLab",
description: "GitLab is a web-based DevOps lifecycle tool that provides a Git repository manager.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitlab.svg",
path: "../../integrations/gitlab",
};
+21 -8
View File
@@ -39,13 +39,26 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
'Scroll down to "API Keys"',
'Click "New API Key"',
'Enter a name, e.g. "Homarr"',
(<span>
Select the following permissions:{" "}
<code>album.read</code>,{" "}
<code>user.read</code>,{" "}
<code>server.statistics</code>,{" "}
<code>asset.download</code>
</span>),
(
<span>
Select the following permissions:{" "}
<code>album.statistics</code>,{" "}
<code>album.read</code>,{" "}
<code>album.create</code>,{" "}
<code>user.read</code>,{" "}
<code>user.update</code>,{" "}
<code>asset.read</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>
),
'Create the integration in Homarr and copy the API key to there'
]
}]} />
}]} />
@@ -1,34 +0,0 @@
---
title: 'LinuxServer.io'
description: "LinuxServer.io is a platform for managing and deploying containerized applications."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { linuxServerIoIntegration } from '.';
import { releasesWidget } from '@site/docs/widgets/releases';
<IntegrationHeader
integration={linuxServerIoIntegration}
categories={['Container Management', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}]} />
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const linuxServerIoIntegration: IntegrationDefinition = {
name: "LinuxServer.io",
description: "LinuxServer.io is a platform for managing and deploying containerized applications.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg",
path: "../../integrations/linux-server-io",
};
-34
View File
@@ -1,34 +0,0 @@
---
title: 'NPM'
description: "NPM is a package manager for the JavaScript programming language, primarily used for managing Node.js packages."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { npmIntegration } from '.';
import { releasesWidget } from '@site/docs/widgets/releases';
<IntegrationHeader
integration={npmIntegration}
categories={['Package Management', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}]} />
-9
View File
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const npmIntegration: IntegrationDefinition = {
name: "NPM",
description:
"NPM is a package manager for the JavaScript programming language, primarily used for managing Node.js packages.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/npm.svg",
path: "../../integrations/npm",
};
@@ -1,37 +0,0 @@
---
title: 'Quay'
description: "Quay is a container registry for storing and managing container images."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { quayIntegration } from '.';
import { releasesWidget } from '@site/docs/widgets/releases';
<IntegrationHeader
integration={quayIntegration}
categories={['Container Registry', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ["personalAccessToken"],
steps: []
}]} />
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const quayIntegration: IntegrationDefinition = {
name: "Quay",
description: "Quay is a container registry for storing and managing container images.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png",
path: "../../integrations/quay",
};
@@ -1,28 +0,0 @@
---
title: 'Search.ch'
description: "Search.ch supports searching for timetables in Switzerland."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { IconDownload } from "@tabler/icons-react";
import { searchChIntegration } from '.';
import Alert from '@theme/Admonition';
import { timetableWidget } from '@site/docs/widgets/timetable';
<IntegrationHeader
integration={searchChIntegration}
categories={['Timetable']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{ widget: timetableWidget }]}
/>
### Adding the integration
<AddingIntegration />
@@ -1,7 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const searchChIntegration: IntegrationDefinition = {
name: "Search.ch",
description: "Search.ch supports searching for timetables in Switzerland.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png",
path: "../../integrations/search-ch",
};
+61
View File
@@ -0,0 +1,61 @@
---
tags:
- Workshop
- Custom Widgets
- Custom CSS
---
# Community Workshop
The [Community Workshop](pathname:///workshop) is where Homarr users share and discover
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
you like, report abuse, and install entries directly into your own Homarr instance.
:::warn
The workshop contains community-made content. Widgets may contain unwanted or malicious content and it is your duty to check & verify safety of the content. While we try to remove malicious content as best as we can, we take no responsibility. Always review the widget JSON and all CSS before installing it on your instance and take frequent backups as prevention measures.
:::
## Browsing and voting
- Filter by **Widgets** or **CSS**, and sort by **Top rated** or **Newest**.
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
- Each submission shows its title, description, author, version, an optional screenshot, and the full content.
## Submitting
1. Sign in with GitHub.
2. Click **Submit** and choose the type:
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
It must match the `homarr-custom-widget-v2` schema (validated before submission).
- **Custom CSS** — paste your stylesheet (max 16384 characters).
3. Add a title, description, optional screenshot, and changelog, then submit.
Submissions are published immediately. Reported entries are reviewed and may be removed.
## Submission guidelines
Submissions must be useful to Homarr users and safe to install. Do not submit content that is
malicious, deceptive, illegal, hateful, sexually explicit, gore, gambling-focused, spam, or content
that primarily promotes unrelated products or services.
## Installing into Homarr
You can install workshop entries without leaving Homarr:
- **Custom widgets** — go to **Manage → Custom Widgets**, click **Install from Workshop**, pick an
entry, and it is imported (secrets such as API keys must be re-entered afterwards).
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it
manually.
## Self-hosting the workshop backend
The workshop is powered by a [PocketBase](https://pocketbase.io) instance (GitHub OAuth, database,
file uploads, and an admin moderation dashboard). The deployment lives in `apps/store/` with a
`docker-compose.yml`, version-controlled collection migrations, and vote-counter hooks. See the
app's `README.md` for GitHub OAuth setup and the `STORE_URL` / `NEXT_PUBLIC_WIDGET_STORE_URL`
configuration.
@@ -9,9 +9,8 @@ export const immichAlbumCarouselWidget: WidgetDefinition = {
configuration: {
items: [
{
name: "Album ID",
description:
"This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'",
name: "Album",
description: "Select the Immich album to show in the slideshow.",
values: { type: "string" },
defaultValue: "",
},
@@ -25,7 +24,13 @@ export const immichAlbumCarouselWidget: WidgetDefinition = {
name: "Show photo info",
description: "Display date of the picture and the current index at the bottom of the slideshow",
values: { type: "boolean" },
defaultValue: "yes",
defaultValue: "no",
},
{
name: "Randomize photos",
description: "Show photos in a random order instead of always starting with the same album order.",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
+19 -45
View File
@@ -1,69 +1,44 @@
---
title: 'Releases'
description: 'Displays a list of the current version of the given repositories with the given version regex.'
title: "Releases"
description: "Displays a list of the current version of the given repositories with the given version regex."
hide_title: true
---
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { releasesWidget } from '.';
import { githubIntegration } from '@site/docs/integrations/github';
import { gitlabIntegration } from '@site/docs/integrations/gitlab';
import { dockerHubIntegration } from '@site/docs/integrations/docker-hub';
import { npmIntegration } from '@site/docs/integrations/npm';
import { codebergIntegration } from '@site/docs/integrations/codeberg';
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { releasesWidget } from ".";
<WidgetHeader
widget={releasesWidget}
categories={['Releases', 'Software']}
/>
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} />
The releases widget enables you to track a list of repositories and see their respective latest release.
It also supports importing releases from your local docker containers.
The releases widget enables you to track a list of repositories and see their respective latest release.
It also supports importing releases from your local docker containers.
### Screenshots
import collapsibleCard from './img/collapsible.png';
import fullCard from './img/full.png';
import noDetailsCard from './img/no-details.png';
import { quayIntegration } from '@site/docs/integrations/quay';
import { gitHubContainerRegistryIntegration } from '@site/docs/integrations/github-containerregistry';
import { linuxServerIoIntegration } from '@site/docs/integrations/linux-server-io';
import collapsibleCard from "./img/collapsible.png";
import fullCard from "./img/full.png";
import noDetailsCard from "./img/no-details.png";
<div className='flex gap-1'>
<div className="flex gap-1">
<img src={fullCard} alt="releases full" width={128 * 3} />
<img src={collapsibleCard} alt="releases collapsible" width={128 * 3} />
<img src={noDetailsCard} alt="releases no details" width={128 * 3} />
</div>
### Supported Integrations
### Supported Providers
<WidgetIntegrations items={[{
integration: githubIntegration,
}, {
integration: gitlabIntegration,
}, {
integration: dockerHubIntegration,
}, {
integration: npmIntegration,
}, {
integration: codebergIntegration,
}, {
integration: gitHubContainerRegistryIntegration,
note: "Use if release is only available in ghcr.io"
}, {
integration: quayIntegration
}, {
integration: linuxServerIoIntegration
}]} />
GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and LinuxServer.io are configured per repository in the widget.
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={releasesWidget.configuration} />
:::note
@@ -74,4 +49,3 @@ Setting the Prefix to `v`, precision to `2` and the Suffix to `-alpha`, will mat
Setting the Prefix to `v`, precision to `3`, will match `v5.2.0` but not `v1.0.2-alpha` or `Beta v5.2.1`.
:::
+11 -4
View File
@@ -54,16 +54,23 @@ export const releasesWidget: WidgetDefinition = {
items: [
{
name: "Provider",
description: "Integration to fetch the repository from.",
values: "Select from the available integrations.",
description: "Provider to fetch the repository or package releases from.",
values: "Select from the available providers.",
defaultValue: "-",
},
{
name: "Identifier",
description: 'The identifier of the repository, e.g. "homarr-labs/homarr".',
values: "Any valid repository identifier.",
description: 'The identifier of the repository or package, e.g. "homarr-labs/homarr".',
values:
'Any valid provider identifier. GHCR also accepts full image refs like "ghcr.io/owner/package:tag".',
defaultValue: "-",
},
{
name: "Provider URL",
description: "Base URL for the provider.",
values: "Any valid URL.",
defaultValue: "Provider default",
},
{
name: "Name",
description: "Display name of the repository.",
+20 -24
View File
@@ -1,44 +1,40 @@
---
title: 'Timetable'
description: 'Displays departure times for a station.'
title: "Timetable"
description: "Displays departure times for a station."
hide_title: true
---
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { timetableWidget } from '.';
import { searchChIntegration } from '@site/docs/integrations/search-ch';
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { timetableWidget } from ".";
<WidgetHeader
widget={timetableWidget}
categories={['Timetable']}
/>
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
The Timetable widget provides real-time departure times for a selected station.
### Screenshots
import trainDepartures from './img/train-departures.png';
import streetDepartures from './img/street-departures.png';
import trainDepartures from "./img/train-departures.png";
import streetDepartures from "./img/street-departures.png";
<div className="flex gap-2">
<img src={trainDepartures} alt="timetable-widget-train-departures" width={128 * 2} />
<img src={streetDepartures} alt="timetable-widget-bus-and-tram-departures" width={128 * 2} />
<img
src={trainDepartures}
alt="timetable-widget-train-departures"
width={128 * 2}
/>
<img
src={streetDepartures}
alt="timetable-widget-bus-and-tram-departures"
width={128 * 2}
/>
</div>
### Adding the widget
<AddingWidget />
### Supported Integrations
<WidgetIntegrations items={[{
integration: searchChIntegration,
note: 'Switzerland only'
}]} />
### Configuration
<WidgetConfig configuration={timetableWidget.configuration} />
@@ -8,6 +8,12 @@ export const timetableWidget: WidgetDefinition = {
path: "../../widgets/timetable",
configuration: {
items: [
{
name: "Search.ch URL",
description: "Base URL for the Search.ch timetable API.",
values: "Any valid URL.",
defaultValue: "https://search.ch",
},
{
name: "Station",
description: "Select the station",
+27 -1
View File
@@ -20,6 +20,10 @@ const config: Config = {
onBrokenAnchors: "throw",
onDuplicateRoutes: "throw",
customFields: {
storeUrl: process.env.STORE_URL ?? "http://localhost:8090",
},
future: {
v4: {
removeLegacyPostBuildHeadAttribute: true,
@@ -105,6 +109,11 @@ const config: Config = {
position: "left",
to: "/blog",
},
{
label: "Workshop",
position: "left",
to: "/workshop",
},
{
label: "About us",
position: "left",
@@ -259,7 +268,12 @@ const config: Config = {
return {
name: "resolve-homarr-packages",
configureWebpack() {
return { resolve: { symlinks: false } };
return {
resolve: {
symlinks: false,
alias: { "@": require("path").resolve(__dirname, "src") },
},
};
},
};
},
@@ -290,6 +304,18 @@ const config: Config = {
},
};
},
function workshopDetailRoutePlugin() {
return {
name: "workshop-detail-route",
async contentLoaded({ actions }) {
actions.addRoute({
path: "/workshop/:id",
component: "@site/src/components/store/DetailPage",
exact: true,
});
},
};
},
"@signalwire/docusaurus-plugin-llms-txt",
async function tailwindCssPlugin() {
return {
+10 -6
View File
@@ -21,6 +21,7 @@
"verify:search": "node ./scripts/verify-algolia-search.mjs"
},
"dependencies": {
"@base-ui/react": "catalog:",
"@docusaurus/core": "^3.10.1",
"@docusaurus/faster": "^3.10.1",
"@docusaurus/plugin-content-docs": "^3.10.1",
@@ -32,19 +33,23 @@
"@scalar/api-reference-react": "catalog:",
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
"@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.1",
"@tailwindcss/postcss": "^4.3.2",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1",
"leader-line-new": "^1.1.9",
"postcss": "^8.5.15",
"pocketbase": "catalog:",
"postcss": "^8.5.16",
"posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1",
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^12.2.0",
"tailwindcss": "^4.3.1"
"swiper": "^14.0.1",
"tailwind-merge": "catalog:",
"tailwindcss": "^4.3.2",
"zod": "catalog:"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.1",
@@ -55,8 +60,7 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"cheerio": "^1.2.0",
"typescript": "catalog:",
"zod": "catalog:"
"typescript": "catalog:"
},
"peerDependencies": {
"@docusaurus/theme-common": "^3.10.1",
@@ -0,0 +1,178 @@
import React, { useEffect, useState } from "react";
import { IconCheck, IconEdit, IconSend, IconTrash, IconX } from "@tabler/icons-react";
import type { StoreComment } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import { errorMessage } from "@site/src/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatRelativeTime } from "./format";
const commentAuthorName = (comment: StoreComment) =>
comment.expand?.author?.name || comment.expand?.author?.username || "unknown";
interface CommentsSectionProps {
submissionId: string;
pb: ReturnType<typeof getPocketBase>;
currentUserId?: string;
onRequireAuth: (action: string) => Promise<string | null>;
onError: (message: string) => void;
}
export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth, onError }: CommentsSectionProps) => {
const [rows, setRows] = useState<StoreComment[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
const [newComment, setNewComment] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
setFetchError(false);
pb.collection("comments")
.getFullList<StoreComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
})
.then((data) => {
if (!cancelled) setRows(data);
})
.catch(() => {
if (!cancelled) setFetchError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [pb, submissionId]);
const handleAdd = async () => {
const trimmed = newComment.trim();
if (!trimmed) return;
const userId = await onRequireAuth("comment");
if (!userId) return;
try {
const created = await pb
.collection("comments")
.create<StoreComment>({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" });
setRows((prev) => [created, ...prev]);
setNewComment("");
} catch (caught) {
onError(errorMessage(caught, "Failed to post comment"));
}
};
const handleUpdate = async (id: string) => {
const trimmed = editContent.trim();
if (!trimmed) return;
try {
const updated = await pb
.collection("comments")
.update<StoreComment>(id, { content: trimmed }, { expand: "author" });
setRows((prev) => prev.map((comment) => (comment.id === id ? updated : comment)));
setEditingId(null);
} catch (caught) {
onError(errorMessage(caught, "Failed to update comment"));
}
};
const handleDelete = async (id: string) => {
try {
await pb.collection("comments").delete(id);
setRows((prev) => prev.filter((comment) => comment.id !== id));
} catch (caught) {
onError(errorMessage(caught, "Failed to delete comment"));
}
};
return (
<div className="flex flex-col gap-3">
<h2 className="text-sm font-medium">Comments</h2>
<div className="flex items-center gap-2">
<Input
className="flex-1"
placeholder="Write a comment…"
aria-label="Write a comment"
value={newComment}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleAdd();
}}
/>
<Button size="icon-sm" onClick={() => void handleAdd()} aria-label="Post comment">
<IconSend size={14} />
</Button>
</div>
{loading && <p className="py-4 text-center text-sm text-muted-foreground">Loading comments…</p>}
{!loading && fetchError && <p className="py-4 text-center text-sm text-destructive">Failed to load comments</p>}
{!loading && !fetchError && rows.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">No comments yet</p>
)}
<div className="space-y-2">
{rows.map((comment) => {
const isOwner = currentUserId === comment.author;
const isEditing = editingId === comment.id;
return (
<div key={comment.id} className="rounded-lg bg-muted/30 px-3 py-2 dark:bg-input/20">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{commentAuthorName(comment)}</span>
<span className="text-muted-foreground/60">{formatRelativeTime(comment.created)}</span>
{isOwner && !isEditing && (
<div className="ml-auto flex gap-0.5">
<button
className="rounded p-1 hover:bg-accent"
aria-label="Edit comment"
onClick={() => {
setEditingId(comment.id);
setEditContent(comment.content);
}}
>
<IconEdit size={12} />
</button>
<button
className="rounded p-1 hover:bg-destructive/20 hover:text-destructive"
aria-label="Delete comment"
onClick={() => void handleDelete(comment.id)}
>
<IconTrash size={12} />
</button>
</div>
)}
</div>
{isEditing ? (
<div className="mt-2 flex items-center gap-1">
<Input
className="flex-1"
aria-label="Edit comment"
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleUpdate(comment.id);
}}
/>
<Button size="icon-sm" aria-label="Save comment" onClick={() => void handleUpdate(comment.id)}>
<IconCheck size={12} />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Cancel editing" onClick={() => setEditingId(null)}>
<IconX size={12} />
</Button>
</div>
) : (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{comment.content}</p>
)}
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,359 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Link from "@docusaurus/Link";
import { useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import {
IconArrowBigDown,
IconArrowBigUp,
IconArrowLeft,
IconCheck,
IconCopy,
IconDownload,
IconX,
} from "@tabler/icons-react";
import type { ClientResponseError } from "pocketbase";
import type { StoreSubmission, StoreVote } from "@site/src/lib/pocketbase";
import { getPocketBase, getSubmissionFileUrl } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/store-schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn, errorMessage } from "@site/src/lib/utils";
import { CommentsSection } from "./DetailComments";
import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } from "./DetailSections";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson, voteDelta } from "./store-utils";
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { css: "css", widget: "json" };
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
] as const;
const scoreClassBySign = { positive: "text-foreground", negative: "text-destructive", neutral: "" } as const;
const scoreSign = (score: number): keyof typeof scoreClassBySign => {
if (score > 0) return "positive";
if (score < 0) return "negative";
return "neutral";
};
const parseSubmissionId = (pathname: string) => {
const segments = pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1];
if (!last || last === "workshop") return null;
return last;
};
const isNotFound = (caught: unknown) =>
typeof caught === "object" && caught !== null && "status" in caught && (caught as ClientResponseError).status === 404;
const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
const location = useLocation();
const submissionId = parseSubmissionId(location.pathname);
const pb = useMemo(() => getPocketBase(storeUrl), [storeUrl]);
const [submission, setSubmission] = useState<StoreSubmission | null>(null);
const [userVote, setUserVote] = useState<StoreVote | undefined>();
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyFailedTimer = useRef<ReturnType<typeof setTimeout>>(null);
const voting = useRef(false);
const requireUserId = useCallback(
async (action: string) => {
if (!pb.authStore.isValid) {
try {
await pb.collection("users").authWithOAuth2({ provider: "github" });
} catch (caught) {
setError(errorMessage(caught, "Sign in failed"));
return null;
}
}
const userId = pb.authStore.record?.id;
if (!userId) {
setError(`Sign in to ${action}`);
return null;
}
return userId;
},
[pb],
);
useEffect(() => {
if (pb.authStore.isValid)
pb.collection("users")
.authRefresh()
.catch(() => pb.authStore.clear());
return pb.authStore.onChange(() => setUser(pb.authStore.record));
}, [pb]);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
if (copyFailedTimer.current) clearTimeout(copyFailedTimer.current);
},
[],
);
useEffect(() => {
if (!submissionId) {
setNotFound(true);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setNotFound(false);
setError(null);
const load = async () => {
try {
const record = await pb.collection("marketplace").getOne<StoreSubmission>(submissionId);
if (cancelled) return;
setSubmission(record);
if (pb.authStore.isValid && pb.authStore.record) {
const votes = await pb.collection("votes").getFullList<StoreVote>({
filter: pb.filter("user = {:uid} && submission = {:sid}", {
uid: pb.authStore.record.id,
sid: submissionId,
}),
});
if (!cancelled) setUserVote(votes[0]);
}
} catch (caught) {
if (cancelled) return;
if (isNotFound(caught)) setNotFound(true);
else setError(errorMessage(caught, "Failed to load submission"));
} finally {
if (!cancelled) setLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [pb, submissionId]);
const screenshotUrls = useMemo(
() => submission?.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
[submission, pb],
);
const handleVote = async (value: 1 | -1) => {
if (!submission || voting.current) return;
voting.current = true;
const userId = await requireUserId("vote");
if (!userId) {
voting.current = false;
return;
}
const prev = userVote;
const isToggleOff = prev?.value === value;
const [upD, downD] = voteDelta(prev?.value, value);
setUserVote(
isToggleOff
? undefined
: ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as StoreVote),
);
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s));
try {
if (!prev) {
const created = await pb
.collection("votes")
.create<StoreVote>({ submission: submission.id, value, user: userId });
setUserVote(created);
} else if (isToggleOff) {
await pb.collection("votes").delete(prev.id);
} else {
await pb.collection("votes").update(prev.id, { value });
}
} catch (caught) {
setUserVote(prev);
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes - upD, downvotes: s.downvotes - downD } : s));
setError(errorMessage(caught, "Failed to register your vote"));
} finally {
voting.current = false;
}
};
const handleDelete = async () => {
if (!submission) return;
try {
await pb.collection("submissions").delete(submission.id);
window.location.href = "/workshop/";
} catch (caught) {
setError(errorMessage(caught, "Failed to delete submission"));
}
};
const handleCopy = async () => {
if (!submission) return;
try {
await navigator.clipboard.writeText(submission.content);
setCopied(true);
setCopyFailed(false);
if (copyTimer.current) clearTimeout(copyTimer.current);
if (copyFailedTimer.current) clearTimeout(copyFailedTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
setCopyFailed(true);
if (copyFailedTimer.current) clearTimeout(copyFailedTimer.current);
copyFailedTimer.current = setTimeout(() => setCopyFailed(false), 2000);
}
};
if (loading) return <DetailSkeleton />;
if (notFound || !submission) {
return (
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
<p className="text-lg font-medium">Submission not found</p>
<p className="mt-1 text-sm text-muted-foreground">This listing may have been removed or the link is invalid.</p>
<Link
to="/workshop"
className="mt-6 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
<IconArrowLeft size={14} /> Back to Workshop
</Link>
</div>
);
}
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
return (
<div className="mx-auto max-w-4xl px-4 pb-16 pt-8">
<Link
to="/workshop"
className="mb-6 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
<IconArrowLeft size={14} /> Back to Workshop
</Link>
{error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{error}
</div>
)}
{screenshotUrls.length > 0 && (
<div className="mb-6">
<ScreenshotGallery urls={screenshotUrls} title={submission.title} />
</div>
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{submission.title}</h1>
<Badge variant="secondary" className="gap-1.5">
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
</p>
</div>
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
onClick={() => void handleVote(1)}
aria-label="Upvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
userVote?.value === 1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigUp size={18} />
</button>
<span
className={cn("min-w-6 text-center text-sm font-semibold tabular-nums", scoreClassBySign[scoreSign(score)])}
>
{score}
</span>
<button
onClick={() => void handleVote(-1)}
aria-label="Downvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
userVote?.value === -1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigDown size={18} />
</button>
</div>
</div>
{submission.description && (
<p className="mt-6 text-sm leading-relaxed text-muted-foreground">{submission.description}</p>
)}
<div className="mt-6 flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void handleCopy()}>
{copyFailed ? (
<>
<IconX size={14} className="text-destructive" /> Copy failed
</>
) : (
<>
<CopyIcon size={14} className={copyIconClass} /> {copyLabel}
</>
)}
</Button>
{submission.type === "widget" && (
<Button variant="outline" size="sm" onClick={() => downloadSubmissionJson(submission)}>
<IconDownload size={14} /> Download
</Button>
)}
{user?.id === submission.author && <DeleteConfirmButton onConfirm={handleDelete} />}
</div>
<div className="mt-6">
<CodeBlock content={submission.content} language={contentLanguages[submission.type]} />
</div>
<div className="mt-10 border-t border-border pt-8">
<CommentsSection
submissionId={submission.id}
pb={pb}
currentUserId={user?.id}
onRequireAuth={requireUserId}
onError={setError}
/>
</div>
</div>
);
};
export default function MarketplaceDetailPage() {
const { siteConfig } = useDocusaurusContext();
const storeUrl = (siteConfig.customFields?.storeUrl as string | undefined) ?? "http://localhost:8090";
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<DetailSkeleton />}>{() => <MarketplaceDetail storeUrl={storeUrl} />}</BrowserOnly>
</main>
</Layout>
);
}
@@ -0,0 +1,123 @@
import React, { useEffect, useRef, useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
import { Highlight, themes } from "prism-react-renderer";
import { Button } from "@/components/ui/button";
import { cn } from "@site/src/lib/utils";
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
const colorModeKeys = { dark: "dark", light: "light" } as const;
const dotClass = ["bg-white/40", "bg-white"];
export const DetailSkeleton = () => (
<div className="mx-auto max-w-4xl px-4 py-8 animate-pulse space-y-6">
<div className="h-4 w-24 rounded bg-muted" />
<div className="aspect-video w-full rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-7 w-2/3 rounded bg-muted" />
<div className="h-4 w-40 rounded bg-muted" />
</div>
<div className="h-24 rounded-lg bg-muted" />
<div className="h-48 rounded-lg bg-muted" />
</div>
);
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const [idx, setIdx] = useState(0);
return (
<div className="group/gallery relative w-full">
<div className="aspect-video overflow-hidden rounded-lg bg-muted">
<img className="h-full w-full object-cover" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
</div>
{urls.length > 1 && (
<>
<button
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
>
<IconChevronLeft size={16} />
</button>
<button
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
>
<IconChevronRight size={16} />
</button>
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5 rounded-full bg-black/50 px-2.5 py-1">
{urls.map((_, i) => (
<button
key={i}
onClick={() => setIdx(i)}
aria-label={`Screenshot ${i + 1}`}
className={cn("size-2 rounded-full transition-all", dotClass[Number(i === idx)])}
/>
))}
</div>
</>
)}
</div>
);
};
export const CodeBlock = ({ content, language }: { content: string; language: string }) => {
const { colorMode } = useColorMode();
const theme = prismThemes[colorModeKeys[colorMode as keyof typeof colorModeKeys] ?? "light"];
return (
<Highlight theme={theme} code={content} language={language}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre
className={cn(className, "overflow-auto rounded-lg border border-border p-4 text-sm leading-relaxed")}
style={style}
>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
);
};
export const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => {
const [pending, setPending] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const handleClick = () => {
if (!pending) {
setPending(true);
timer.current = setTimeout(() => setPending(false), 3000);
return;
}
setPending(false);
onConfirm();
};
return (
<Button
variant="outline"
size="sm"
className={cn(
pending ? "border-destructive bg-destructive/10 text-destructive" : "text-destructive hover:bg-destructive/10",
)}
onClick={handleClick}
>
<IconTrash size={14} /> {pending ? "Confirm delete?" : "Delete"}
</Button>
);
};
+523
View File
@@ -0,0 +1,523 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
IconArrowBigDown,
IconArrowBigUp,
IconBrandCss3,
IconBrandGithub,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconCopy,
IconDownload,
IconFlag,
IconLogout,
IconMessage,
IconPackage,
IconPlus,
IconPuzzle,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { getSubmissionFileUrl, type StoreSubmission } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/store-schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { SubmitForm } from "./SubmitForm";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson } from "./store-utils";
import type { SortKey, TypeFilter } from "./useStore";
import { useStore } from "./useStore";
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number; className?: string }>> = {
css: IconBrandCss3,
widget: IconPuzzle,
};
const typeBgColors: Record<SubmissionType, string> = { css: "bg-blue-500/5", widget: "bg-yellow-500/5" };
const filterActiveClass = "bg-background text-foreground shadow-sm";
const filterInactiveClass = "text-muted-foreground hover:text-foreground";
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
] as const;
const emptyState = {
none: { title: "No submissions yet", hint: "Be the first to share something." },
filtered: { title: "No matching results", hint: "Try adjusting your filters or search." },
} as const;
const typeFilters: { value: TypeFilter; label: string; dot?: string }[] = [
{ value: "all", label: "All" },
{ value: "widget", label: "Widgets", dot: typeDotColors.widget },
{ value: "css", label: "CSS", dot: typeDotColors.css },
{ value: "yours", label: "Yours" },
];
const sortOptions: { value: SortKey; label: string }[] = [
{ value: "top", label: "Top rated" },
{ value: "new", label: "Newest" },
];
const stopCardNavigation = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
};
export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
const store = useStore(storeUrl);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [sort, setSort] = useState<SortKey>("top");
const [search, setSearch] = useState("");
const [showSubmit, setShowSubmit] = useState(false);
// Stable sort order: only re-sort when filter/sort/search or submission set changes (not vote counts)
const submissionIds = useMemo(() => store.submissions.map((s) => s.id).join(","), [store.submissions]);
const sortedIds = useMemo(() => {
const q = search.toLowerCase();
return store.submissions
.filter((item) => {
if (typeFilter === "yours") return item.author === store.user?.id;
return typeFilter === "all" || item.type === typeFilter;
})
.filter((item) => !q || item.title.toLowerCase().includes(q))
.toSorted(store.sorters[sort])
.map((s) => s.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array
}, [submissionIds, store.sorters, typeFilter, sort, search, store.user?.id]);
const visible = useMemo(() => {
const byId = new Map(store.submissions.map((s) => [s.id, s]));
return sortedIds.map((id) => byId.get(id)).filter(Boolean) as StoreSubmission[];
}, [store.submissions, sortedIds]);
const empty = emptyState[store.submissions.length === 0 ? "none" : "filtered"];
return (
<div className="mx-auto max-w-7xl px-4 pb-16">
<div className="flex flex-col gap-6 py-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Workshop</h1>
<p className="mt-1 text-sm text-muted-foreground">Community custom widgets and CSS themes for Homarr.</p>
</div>
<div className="flex items-center gap-2">
{store.user ? (
<>
<Button onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Share yours
</Button>
<Button variant="ghost" size="sm" onClick={store.logout}>
<IconLogout size={14} /> {store.user?.name || store.user?.username || "Account"}
</Button>
</>
) : (
<div className="flex flex-col items-end gap-0.5">
<Button onClick={() => void store.login()}>
<IconBrandGithub size={14} /> Sign in with GitHub
</Button>
<p className="text-xs text-muted-foreground">Vote and share your creations</p>
</div>
)}
</div>
</div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2">
<div className="flex items-center gap-2">
<div className="inline-flex rounded-md bg-muted p-0.5">
{typeFilters.map((opt) => (
<button
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors",
typeFilter === opt.value ? filterActiveClass : filterInactiveClass,
)}
>
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
{opt.label}
</button>
))}
</div>
<select
aria-label="Sort order"
className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none dark:bg-input/30"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
>
{sortOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="relative">
<IconSearch size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
className="h-7 w-48 rounded-md border border-input bg-transparent pl-8 text-xs outline-none placeholder:text-muted-foreground"
placeholder="Search…"
aria-label="Search submissions"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{store.error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{store.error}
</div>
)}
{store.loading && (
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 2xl:columns-4 [&>*]:mb-4">
{Array.from({ length: 6 }, (_, i) => (
<SkeletonCard key={i} hasImage={i % 3 === 0} />
))}
</div>
)}
{!store.loading && visible.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border py-16">
<IconPackage size={28} stroke={1.5} className="text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">{empty.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{empty.hint}</p>
</div>
{store.user && store.submissions.length === 0 && (
<Button size="sm" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Create submission
</Button>
)}
</div>
)}
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 [&>*]:mb-4">
{visible.map((submission) => (
<SubmissionCard
key={submission.id}
submission={submission}
pb={store.pb}
userVote={store.votes[submission.id]?.value}
currentUserId={store.user?.id}
onVote={store.vote}
onReport={store.report}
onDelete={store.deleteSubmission}
/>
))}
</div>
{showSubmit && (
<SubmitForm
onClose={() => setShowSubmit(false)}
onSubmit={async (input) => {
if (await store.submit(input)) setShowSubmit(false);
}}
/>
)}
</div>
);
};
interface SubmissionCardProps {
submission: StoreSubmission;
pb: ReturnType<typeof useStore>["pb"];
userVote?: 1 | -1;
currentUserId?: string;
onVote: (submissionId: string, value: 1 | -1) => void;
onReport: (submissionId: string, reason: string) => void;
onDelete: (submissionId: string) => Promise<boolean>;
}
const SubmissionCard = ({
submission,
pb,
userVote,
currentUserId,
onVote,
onReport,
onDelete,
}: SubmissionCardProps) => {
const isOwner = currentUserId === submission.author;
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const confirmTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
const screenshotUrls = useMemo(
() => submission.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
[submission, pb],
);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
},
[],
);
const [copyFailed, setCopyFailed] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(submission.content);
setCopied(true);
setCopyFailed(false);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 2000);
}
};
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
confirmTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
return;
}
setConfirmDelete(false);
void onDelete(submission.id);
};
const hasScreenshots = screenshotUrls.length > 0;
const TypeIcon = typeIcons[submission.type];
return (
<Card className="relative transition-transform duration-150 hover:scale-[1.02]">
<a href={`/workshop/${submission.id}/`} className="block">
{hasScreenshots ? (
<div className="relative">
<Badge
variant="secondary"
className="absolute left-2 top-2 z-10 gap-1.5 bg-background/80 px-2 backdrop-blur-sm"
>
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
<ScreenshotGallery urls={screenshotUrls} title={submission.title} />
</div>
) : (
<div className={cn("flex items-center justify-center py-6", typeBgColors[submission.type])}>
<TypeIcon size={32} className="text-muted-foreground/20" />
</div>
)}
</a>
<CardHeader>
<div className="flex items-center gap-2">
<a href={`/workshop/${submission.id}/`} className="min-w-0 hover:underline" title={submission.title}>
<CardTitle className="truncate">{submission.title}</CardTitle>
</a>
{!hasScreenshots && (
<Badge variant="secondary" className="shrink-0 gap-1.5 px-2">
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
)}
</div>
<CardDescription className="text-xs">
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
</CardDescription>
<CardAction>
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
onClick={() => void onVote(submission.id, 1)}
aria-label="Upvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
userVote === 1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigUp size={14} />
</button>
<span
className={cn(
"min-w-5 text-center text-xs font-semibold tabular-nums",
score > 0 && "text-foreground",
score < 0 && "text-destructive",
)}
>
{score}
</span>
<button
onClick={() => void onVote(submission.id, -1)}
aria-label="Downvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
userVote === -1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigDown size={14} />
</button>
</div>
</CardAction>
</CardHeader>
<CardContent>
{submission.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{submission.description}</p>
)}
</CardContent>
<CardFooter className="flex-col gap-0 p-0">
<div className="flex w-full flex-wrap items-center gap-0.5 px-2 py-1.5">
<Button
variant="ghost"
size="xs"
onClick={() => void handleCopy()}
className="text-muted-foreground hover:text-foreground"
>
{copyFailed ? (
<>
<IconX size={13} className="text-destructive" /> Failed
</>
) : (
<>
<CopyIcon size={13} className={copyIconClass} /> {copyLabel}
</>
)}
</Button>
{submission.type === "widget" && (
<Button
variant="ghost"
size="xs"
onClick={() => downloadSubmissionJson(submission)}
className="text-muted-foreground hover:text-foreground"
>
<IconDownload size={13} /> Download
</Button>
)}
{submission.commentCount > 0 && (
<a
href={`/workshop/${submission.id}/`}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
<IconMessage size={13} /> {submission.commentCount}
</a>
)}
<div className="ml-auto flex items-center gap-px">
{isOwner && (
<button
className={cn(
"flex items-center gap-1 rounded px-1 py-0.5 text-xs transition-colors",
confirmDelete
? "bg-destructive/10 text-destructive"
: "text-muted-foreground/60 hover:text-destructive",
)}
onClick={handleDelete}
aria-label="Delete"
>
<IconTrash size={13} />
{confirmDelete && "Confirm?"}
</button>
)}
<button
className="flex items-center justify-center rounded p-1 text-muted-foreground/60 transition-colors hover:text-destructive"
onClick={() => void onReport(submission.id, "flagged")}
aria-label="Report"
>
<IconFlag size={13} />
</button>
</div>
</div>
</CardFooter>
</Card>
);
};
const SkeletonCard = ({ hasImage }: { hasImage: boolean }) => (
<Card>
{hasImage && <div className="aspect-video animate-pulse bg-muted" />}
<CardHeader>
<div className="flex items-center gap-2">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-4 w-12 animate-pulse rounded-full bg-muted" />
</div>
<div className="h-3 w-40 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
</div>
</CardContent>
<CardFooter className="p-0">
<div className="flex w-full gap-2 px-2 py-2">
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-16 animate-pulse rounded bg-muted" />
</div>
</CardFooter>
</Card>
);
const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const [idx, setIdx] = useState(0);
const dotClass = ["bg-white/40", "bg-white"];
return (
<div className="group/gallery relative">
<div className="aspect-video overflow-hidden bg-muted">
<img
className="h-full w-full object-cover"
src={urls[idx]}
alt={`${title} screenshot ${idx + 1}`}
loading="lazy"
/>
</div>
{urls.length > 1 && (
<>
<button
className="absolute left-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i - 1 + urls.length) % urls.length);
}}
aria-label="Previous screenshot"
>
<IconChevronLeft size={14} />
</button>
<button
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i + 1) % urls.length);
}}
aria-label="Next screenshot"
>
<IconChevronRight size={14} />
</button>
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 gap-1 rounded-full bg-black/50 px-2 py-1">
{urls.map((_, i) => (
<button
key={i}
onClick={(event) => {
stopCardNavigation(event);
setIdx(i);
}}
aria-label={`Screenshot ${i + 1}`}
className={cn("size-1.5 rounded-full transition-all", dotClass[Number(i === idx)])}
/>
))}
</div>
</>
)}
</div>
);
};
@@ -0,0 +1,542 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
IconBraces,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconFileUpload,
IconLoader2,
IconPalette,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import type { SubmissionType } from "@site/src/lib/store-schema";
import { validateSubmissionContent } from "@site/src/lib/store-schema";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn, errorMessage } from "@/lib/utils";
import type { SubmitInput } from "./useStore";
interface Props {
onClose: () => void;
onSubmit: (input: SubmitInput) => Promise<void>;
}
const steps = ["Type", "Details", "Media"] as const;
const navDirection = [-1, 1];
const stepAnimClass = ["submit-step-from-left", "submit-step-from-right"];
const backLabels = ["Cancel", "Back"];
const submitLabels = ["Submit", "Submitting…"];
const connectorClass = ["bg-border", "bg-primary"];
const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"];
const placeholders: Record<SubmissionType, string> = {
widget: '{\n "$schema": "homarr-custom-widget-v2",\n "name": "My widget",\n ...\n}',
css: ".grid-stack-item-content {\n border-radius: 16px;\n}",
};
const contentLabels: Record<SubmissionType, string> = {
widget: "Widget JSON (homarr-custom-widget-v2)",
css: "Custom CSS",
};
const parseJsonObject = (json: string): Record<string, unknown> | null => {
try {
const parsed = JSON.parse(json);
if (typeof parsed === "object" && parsed !== null) return parsed;
} catch {
/* invalid json */
}
return null;
};
export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const [step, setStep] = useState(0);
const [direction, setDirection] = useState(1);
const [type, setType] = useState<SubmissionType | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [jsonDropHint, setJsonDropHint] = useState(false);
const previewUrlsRef = useRef<Set<string>>(new Set());
const canAdvance = [type !== null, title.trim().length >= 3 && content.trim().length > 0, true];
const goTo = (next: number) => {
setDirection(navDirection[Number(next > step)]);
setStep(next);
};
const setContentAndAutofill = useCallback((json: string, currentTitle: string, currentDesc: string) => {
setContent(json);
const meta = parseJsonObject(json);
if (!meta) return;
if (typeof meta.name === "string" && !currentTitle) setTitle(meta.name);
if (typeof meta.description === "string" && !currentDesc) setDescription(String(meta.description));
}, []);
const handleJsonFileDrop = useCallback(
(files: FileList | File[]) => {
const jsonFile = Array.from(files).find((f) => f.name.endsWith(".json") || f.type === "application/json");
if (!jsonFile) return false;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (!type) setType("widget");
setContentAndAutofill(text, title, description);
if (step === 0) goTo(1);
};
reader.onerror = () => setError("Could not read the file");
reader.readAsText(jsonFile);
return true;
},
[type, step, title, description, setContentAndAutofill],
);
const addImageFiles = useCallback(
(files: FileList | File[]) => {
const incoming = Array.from(files)
.filter((f) => f.type.startsWith("image/"))
.slice(0, 5 - screenshots.length);
if (incoming.length === 0) return;
setScreenshots((prev) => [...prev, ...incoming].slice(0, 5));
const urls = incoming.map((f) => {
const url = URL.createObjectURL(f);
previewUrlsRef.current.add(url);
return url;
});
setPreviews((prev) => [...prev, ...urls].slice(0, 5));
},
[screenshots.length],
);
const removeScreenshot = (idx: number) => {
const url = previews[idx];
if (url) {
previewUrlsRef.current.delete(url);
URL.revokeObjectURL(url);
}
setScreenshots((prev) => prev.filter((_, i) => i !== idx));
setPreviews((prev) => prev.filter((_, i) => i !== idx));
};
useEffect(
() => () => {
previewUrlsRef.current.forEach(URL.revokeObjectURL);
previewUrlsRef.current.clear();
},
[],
);
const handleGlobalDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setJsonDropHint(false);
if (!handleJsonFileDrop(e.dataTransfer.files) && step === 2) addImageFiles(e.dataTransfer.files);
},
[handleJsonFileDrop, step, addImageFiles],
);
const handleSubmit = async () => {
if (!type) return;
const validation = validateSubmissionContent(type, content);
if (!validation.success) {
setError(validation.error);
goTo(1);
return;
}
setError(null);
setPending(true);
try {
await onSubmit({ type, title, description, content, screenshots });
} catch (caught) {
setError(errorMessage(caught, "Submission failed"));
} finally {
setPending(false);
}
};
const onContentChange = (value: string) => {
if (type === "widget") setContentAndAutofill(value, title, description);
else setContent(value);
};
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending) onClose();
}}
>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<DialogContent
className="max-h-[90vh] overflow-y-auto sm:max-w-xl"
showCloseButton={!pending}
onDragOver={(e: React.DragEvent) => {
e.preventDefault();
setJsonDropHint(true);
}}
onDragLeave={() => setJsonDropHint(false)}
onDrop={handleGlobalDrop}
>
<div
aria-hidden={!jsonDropHint}
className={cn(
"absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-xl bg-primary/10 backdrop-blur-sm transition-opacity duration-200",
dropOverlayClass[Number(jsonDropHint)],
)}
>
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<IconFileUpload size={28} />
</div>
<p className="text-sm font-semibold">Drop JSON file to import widget</p>
<p className="text-xs text-muted-foreground">Title and description will be auto-filled</p>
</div>
<DialogHeader>
<DialogTitle>Share with the community</DialogTitle>
<DialogDescription>Submit a custom CSS theme or widget to the Workshop.</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
{steps.map((label, i) => (
<React.Fragment key={label}>
{i > 0 && <div className={cn("h-px flex-1 transition-colors", connectorClass[Number(i <= step)])} />}
<button
onClick={() => {
if (i < step) goTo(i);
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all",
i === step && "bg-primary text-primary-foreground shadow-sm",
i < step && "bg-primary/10 text-primary cursor-pointer hover:bg-primary/20",
i > step && "bg-muted text-muted-foreground",
)}
>
{i < step ? <IconCheck size={12} /> : <span className="tabular-nums">{i + 1}</span>}
{label}
</button>
</React.Fragment>
))}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive">
{error}
</div>
)}
<div className="relative min-h-[280px] overflow-hidden">
<div key={step} className={cn("submit-step-enter", stepAnimClass[Number(direction > 0)])}>
{step === 0 && <StepType value={type} onChange={setType} onJsonDrop={handleJsonFileDrop} />}
{step === 1 && type && (
<StepDetails
type={type}
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
content={content}
onContentChange={onContentChange}
/>
)}
{step === 2 && (
<StepMedia previews={previews} addFiles={addImageFiles} removeScreenshot={removeScreenshot} />
)}
</div>
</div>
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
onClick={() => {
if (step === 0) onClose();
else goTo(step - 1);
}}
disabled={pending}
>
<IconChevronLeft size={14} /> {backLabels[Math.min(step, 1)]}
</Button>
<div className="flex items-center gap-2">
{step < 2 && (
<Button onClick={() => goTo(step + 1)} disabled={!canAdvance[step]}>
Next <IconChevronRight size={14} />
</Button>
)}
{step >= 2 && (
<Button onClick={() => void handleSubmit()} disabled={pending || !canAdvance[1]}>
{pending && <IconLoader2 size={14} className="animate-spin" />}
{submitLabels[Number(pending)]}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string; desc: string }[] = [
{
type: "widget",
icon: IconBraces,
label: "Custom Widget",
desc: "A JSON-based widget using the homarr-custom-widget-v2 schema",
},
{ type: "css", icon: IconPalette, label: "Custom CSS", desc: "A CSS theme or style override for Homarr dashboards" },
];
const StepType = ({
value,
onChange,
onJsonDrop,
}: {
value: SubmissionType | null;
onChange: (t: SubmissionType) => void;
onJsonDrop: (files: FileList | File[]) => boolean;
}) => {
const [dropActive, setDropActive] = useState(false);
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
{typeCards.map(({ type, icon: Icon, label, desc }) => (
<button
key={type}
onClick={() => onChange(type)}
className={cn(
"group flex flex-col items-center gap-3 rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:bg-primary/5",
value === type ? "border-primary bg-primary/10 ring-1 ring-primary/20" : "border-border bg-card",
)}
>
<div
className={cn(
"flex size-12 items-center justify-center rounded-xl transition-colors",
value === type
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Icon size={24} />
</div>
<div>
<p className="font-heading text-sm font-semibold">{label}</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{desc}</p>
</div>
{value === type && (
<div className="flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<IconCheck size={12} />
</div>
)}
</button>
))}
</div>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDropActive(true);
}}
onDragLeave={() => setDropActive(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDropActive(false);
onJsonDrop(e.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl border border-dashed p-3 transition-all hover:border-primary/40 hover:bg-primary/5",
dropActive && "border-primary bg-primary/10",
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<IconFileUpload size={16} />
</div>
<div className="min-w-0">
<p className="text-xs font-medium">Or drop a .json file here</p>
<p className="text-xs text-muted-foreground">Auto-selects Widget type and fills title from JSON</p>
</div>
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
if (e.target.files) onJsonDrop(e.target.files);
}}
/>
</label>
</div>
);
};
const StepDetails = ({
type,
title,
setTitle,
description,
setDescription,
content,
onContentChange,
}: {
type: SubmissionType;
title: string;
setTitle: (v: string) => void;
description: string;
setDescription: (v: string) => void;
content: string;
onContentChange: (v: string) => void;
}) => (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-content" className="text-xs font-medium text-muted-foreground">
{contentLabels[type]} *
</label>
<Textarea
id="submit-content"
className="font-mono text-xs"
value={content}
onChange={(e) => onContentChange(e.target.value)}
placeholder={placeholders[type]}
rows={8}
required
/>
{type === "widget" && (
<p className="text-xs text-muted-foreground">
Paste your widget JSON — title and description will auto-fill from the{" "}
<code className="rounded bg-muted px-1 py-0.5 text-foreground">name</code> field.
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-title" className="text-xs font-medium text-muted-foreground">
Title *
</label>
<Input
id="submit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
minLength={3}
maxLength={100}
placeholder="My awesome theme"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-description" className="text-xs font-medium text-muted-foreground">
Description
</label>
<Textarea
id="submit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={2}
placeholder="A brief description of what this does"
/>
</div>
</div>
);
const mediaDropState = {
idle: { Icon: IconUpload, title: "Drag & drop or click to upload", iconClass: "bg-muted text-muted-foreground" },
active: { Icon: IconPhoto, title: "Drop images here", iconClass: "bg-primary text-primary-foreground" },
} as const;
const StepMedia = ({
previews,
addFiles,
removeScreenshot,
}: {
previews: string[];
addFiles: (files: FileList | File[]) => void;
removeScreenshot: (idx: number) => void;
}) => {
const [dragOver, setDragOver] = useState(false);
const { Icon: DropIcon, title: dropTitle, iconClass: dropIconClass } = mediaDropState[dragOver ? "active" : "idle"];
return (
<div className="flex flex-col gap-4">
<div>
<span className="text-xs font-medium text-muted-foreground">Screenshots (optional, up to 5)</span>
<p className="mt-0.5 text-xs text-muted-foreground/70">
Add screenshots to help others preview your submission.
</p>
</div>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
addFiles(e.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed border-border p-8 text-center transition-all hover:border-primary/50 hover:bg-primary/5",
dragOver && "border-primary bg-primary/10",
previews.length >= 5 && "pointer-events-none opacity-50",
)}
>
<div className={cn("flex size-10 items-center justify-center rounded-xl transition-colors", dropIconClass)}>
<DropIcon size={20} />
</div>
<div>
<p className="font-heading text-sm font-medium">{dropTitle}</p>
<p className="mt-0.5 text-xs text-muted-foreground">PNG, JPG, WebP up to 5 images</p>
</div>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
}}
/>
</label>
{previews.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{previews.map((src, i) => (
<div
key={i}
className="group relative aspect-square overflow-hidden rounded-xl border border-border bg-muted"
>
<img src={src} alt={`Preview ${i + 1}`} className="h-full w-full object-cover" />
<button
onClick={() => removeScreenshot(i)}
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove screenshot ${i + 1}`}
>
<IconX size={16} className="text-white" />
</button>
</div>
))}
</div>
)}
</div>
);
};
+6
View File
@@ -0,0 +1,6 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export const formatRelativeTime = (date: string) => dayjs(date).fromNow();
@@ -0,0 +1,16 @@
import type { StoreSubmission } from "@site/src/lib/pocketbase";
export const downloadSubmissionJson = (submission: StoreSubmission) => {
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
Object.assign(document.createElement("a"), {
href: url,
download: `${submission.title.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}.json`,
}).click();
URL.revokeObjectURL(url);
};
export const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => {
if (!prev) return next === 1 ? [1, 0] : [0, 1];
if (prev === next) return next === 1 ? [-1, 0] : [0, -1];
return next === 1 ? [1, -1] : [-1, 1];
};
+349
View File
@@ -0,0 +1,349 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import type { StoreComment, StoreSubmission, StoreVote } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/store-schema";
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/store-schema";
import { errorMessage } from "@site/src/lib/utils";
import { voteDelta } from "./store-utils";
export type SortKey = "top" | "new";
export type TypeFilter = "all" | "yours" | SubmissionType;
export interface SubmitInput {
type: SubmissionType;
title: string;
description: string;
content: string;
screenshots: File[];
}
export interface CommentActions {
fetch: (submissionId: string) => Promise<StoreComment[]>;
add: (submissionId: string, content: string) => Promise<StoreComment | null>;
update: (commentId: string, content: string) => Promise<StoreComment | null>;
delete: (commentId: string) => Promise<boolean>;
}
const sorters: Record<SortKey, (a: StoreSubmission, b: StoreSubmission) => number> = {
top: (a, b) => b.upvotes - b.downvotes - (a.upvotes - a.downvotes),
new: (a, b) => dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
};
const isNotFoundError = (error: unknown) =>
typeof error === "object" && error !== null && "status" in error && error.status === 404;
export const useStore = (storeUrl: string) => {
const pb = useMemo(() => getPocketBase(storeUrl), [storeUrl]);
const [submissions, setSubmissions] = useState<StoreSubmission[]>([]);
const [votes, setVotes] = useState<Record<string, StoreVote>>({});
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const votingIds = useRef(new Set<string>());
const refreshVotes = useCallback(async () => {
if (!pb.authStore.isValid || !pb.authStore.record) {
setVotes({});
return;
}
const rows = await pb.collection("votes").getFullList<StoreVote>({
filter: pb.filter("user = {:id}", { id: pb.authStore.record.id }),
});
setVotes(Object.fromEntries(rows.map((row) => [row.submission, row])));
}, [pb]);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
setSubmissions(await pb.collection("marketplace").getFullList<StoreSubmission>({ sort: "-created" }));
await refreshVotes();
} catch (caught) {
setError(errorMessage(caught, "Failed to load the workshop"));
} finally {
setLoading(false);
}
}, [pb, refreshVotes]);
useEffect(() => {
let cancelled = false;
const unsubscribe = pb.authStore.onChange(() => {
setUser(pb.authStore.record);
if (!pb.authStore.isValid) setVotes({});
});
const load = async () => {
if (pb.authStore.isValid) {
try {
await pb.collection("users").authRefresh();
} catch {
pb.authStore.clear();
}
}
if (!cancelled) await refresh();
};
void load();
return () => {
cancelled = true;
unsubscribe();
};
}, [pb, refresh]);
const ensureAuth = useCallback(async () => {
if (pb.authStore.isValid) {
try {
await pb.collection("users").authRefresh();
return true;
} catch {
pb.authStore.clear();
}
}
await pb.collection("users").authWithOAuth2({ provider: "github" });
await refreshVotes();
return pb.authStore.isValid;
}, [pb, refreshVotes]);
const requireUserId = useCallback(
async (action: string) => {
let authenticated = false;
try {
authenticated = await ensureAuth();
} catch (caught) {
setError(errorMessage(caught, `Sign in to ${action}`));
return null;
}
if (!authenticated) {
setError(`Sign in to ${action}`);
return null;
}
const userId = pb.authStore.record?.id;
if (!userId) {
setError(`Sign in to ${action}`);
return null;
}
return userId;
},
[pb, ensureAuth],
);
const login = useCallback(async () => {
try {
await ensureAuth();
setError(null);
} catch (caught) {
setError(errorMessage(caught, "Sign in failed"));
}
}, [ensureAuth]);
const vote = useCallback(
async (submissionId: string, value: 1 | -1) => {
if (votingIds.current.has(submissionId)) return;
votingIds.current.add(submissionId);
const userId = await requireUserId("vote");
if (!userId) {
votingIds.current.delete(submissionId);
return;
}
const prev = votes[submissionId];
const isToggleOff = prev?.value === value;
const [upD, downD] = voteDelta(prev?.value, value);
setVotes((v) => {
const next = { ...v };
if (isToggleOff) delete next[submissionId];
else
next[submissionId] = {
...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }),
value,
} as StoreVote;
return next;
});
setSubmissions((s) =>
s.map((sub) =>
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes + upD, downvotes: sub.downvotes + downD } : sub,
),
);
try {
const existing = prev?.id ? prev : undefined;
if (!existing) {
const created = await pb
.collection("votes")
.create<StoreVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
} else if (isToggleOff) {
try {
await pb.collection("votes").delete(existing.id);
} catch (caught) {
if (!isNotFoundError(caught)) throw caught;
}
} else {
try {
const updated = await pb.collection("votes").update<StoreVote>(existing.id, { value });
setVotes((current) => ({ ...current, [submissionId]: updated }));
} catch (caught) {
if (!isNotFoundError(caught)) throw caught;
const created = await pb
.collection("votes")
.create<StoreVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
}
}
} catch (caught) {
setVotes((v) => {
const next = { ...v };
if (prev) next[submissionId] = prev;
else delete next[submissionId];
return next;
});
setSubmissions((s) =>
s.map((sub) =>
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes - upD, downvotes: sub.downvotes - downD } : sub,
),
);
setError(errorMessage(caught, "Failed to register your vote"));
} finally {
votingIds.current.delete(submissionId);
}
},
[pb, votes, requireUserId],
);
const report = useCallback(
async (submissionId: string, reason: string) => {
try {
const userId = await requireUserId("report");
if (!userId) return;
await pb.collection("reports").create({ submission: submissionId, reason, user: userId });
} catch (caught) {
setError(errorMessage(caught, "Failed to submit your report"));
}
},
[pb, requireUserId],
);
const deleteSubmission = useCallback(
async (submissionId: string) => {
try {
await pb.collection("submissions").delete(submissionId);
setSubmissions((prev) => prev.filter((s) => s.id !== submissionId));
return true;
} catch (caught) {
setError(errorMessage(caught, "Failed to delete submission"));
return false;
}
},
[pb],
);
const submit = useCallback(
async (input: SubmitInput): Promise<boolean> => {
const validation = validateSubmissionContent(input.type, input.content);
if (!validation.success) throw new Error(validation.error);
if (!(await ensureAuth())) throw new Error("Sign in required to submit");
const userId = pb.authStore.record?.id;
if (!userId) throw new Error("Sign in required to submit");
const data = new FormData();
data.set("type", input.type);
data.set("title", input.title);
data.set("description", input.description);
data.set("schemaVersion", schemaVersionByType[input.type]);
data.set("content", input.content);
data.set("author", userId);
for (const file of input.screenshots) data.append("screenshots", file);
await pb.collection("submissions").create(data);
await refresh();
return true;
},
[pb, ensureAuth, refresh],
);
const fetchComments = useCallback(
(submissionId: string) =>
pb.collection("comments").getFullList<StoreComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
}),
[pb],
);
const addComment = useCallback(
async (submissionId: string, content: string) => {
const userId = await requireUserId("comment");
if (!userId) return null;
try {
return await pb
.collection("comments")
.create<StoreComment>({ submission: submissionId, content, author: userId }, { expand: "author" });
} catch (caught) {
setError(errorMessage(caught, "Failed to post comment"));
return null;
}
},
[pb, requireUserId],
);
const updateComment = useCallback(
async (commentId: string, content: string) => {
try {
return await pb.collection("comments").update<StoreComment>(commentId, { content }, { expand: "author" });
} catch (caught) {
setError(errorMessage(caught, "Failed to update comment"));
return null;
}
},
[pb],
);
const deleteComment = useCallback(
async (commentId: string) => {
try {
await pb.collection("comments").delete(commentId);
return true;
} catch (caught) {
setError(errorMessage(caught, "Failed to delete comment"));
return false;
}
},
[pb],
);
const logout = useCallback(() => pb.authStore.clear(), [pb]);
const comments = useMemo<CommentActions>(
() => ({
fetch: fetchComments,
add: addComment,
update: updateComment,
delete: deleteComment,
}),
[fetchComments, addComment, updateComment, deleteComment],
);
return {
pb,
submissions,
votes,
user,
loading,
error,
sorters,
comments,
refresh,
login,
logout,
vote,
report,
submit,
deleteSubmission,
};
};
+46 -10
View File
@@ -1,13 +1,49 @@
import { ReactNode } from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
interface BadgeProps {
children: ReactNode;
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
}
export const Badge = ({ children }: BadgeProps) => {
return (
<span className="inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap overflow-hidden border-transparent dark:bg-slate-700 bg-slate-200">
{children}
</span>
);
};
export { Badge, badgeVariants };
+50
View File
@@ -0,0 +1,50 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
+70
View File
@@ -0,0 +1,70 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, size = "default", ...props }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex break-inside-avoid flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center rounded-b-xl border-t border-border bg-muted/50 p-(--card-spacing)", className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
+134
View File
@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { IconX } from "@tabler/icons-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />;
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-base leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { ReactNode } from "react";
import type { ReactNode } from "react";
import { Badge } from "../ui/badge";
interface DocsHeaderProps {
@@ -18,7 +18,7 @@ export const DocsHeader = (props: DocsHeaderProps) => {
<h1 className="!mb-0 text-4xl">{props.title}</h1>
<div className="flex gap-2">
{props.categories.map((category) => (
<Badge>{category}</Badge>
<Badge key={category}>{category}</Badge>
))}
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
+167
View File
@@ -0,0 +1,167 @@
"use client";
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "@/lib/utils";
import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-react";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return <SelectPrimitive.Group data-slot="select-group" className={cn("scroll-my-1 p-1", className)} {...props} />;
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value data-slot="select-value" className={cn("flex flex-1 text-left", className)} {...props} />
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<IconSelector className="pointer-events-none size-4 text-muted-foreground" />} />
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<SelectPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger">) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<IconChevronUp />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<IconChevronDown />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+19
View File
@@ -0,0 +1,19 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
+180 -16
View File
@@ -1,13 +1,56 @@
@import url("https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap");
@import "tailwindcss/theme" layer(theme);
@import "tailwindcss/utilities" layer(utilities);
@import url(https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap);
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme {
/* shadcn preset b4gOK4dHc — base-nova style, mist base color */
@theme inline {
--spacing-128: 32rem;
--breakpoint-3xl: 1697px;
--font-heading: var(--font-sans);
--font-sans: "Work Sans", sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
@@ -24,14 +67,52 @@
--aa-primary-color-rgb: #fa5252 !important;
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 200,200,200 !important;
--aa-background-color-rgb: 200, 200, 200 !important;
--ifm-font-family-base: 'Work Sans', sans-serif;
--ifm-font-family-base: "Work Sans", sans-serif;
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
/* Bridge Docusaurus to shadcn theme */
--ifm-background-color: var(--background);
--ifm-font-color-base: var(--foreground);
--ifm-navbar-background-color: var(--card);
--ifm-footer-background-color: var(--card);
--radius: 0.45rem;
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.514 0.222 16.935);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.586 0.253 17.585);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
}
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme='dark'] {
[data-theme="dark"] {
--ifm-color-primary: #fa5252;
--ifm-color-primary-dark: #f93232;
--ifm-color-primary-darker: #f92222;
@@ -40,13 +121,68 @@
--ifm-color-primary-lighter: #fb8282;
--ifm-color-primary-lightest: #fdc3c3;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
--homarr-secondary-bg: #181818;
--homarr-secondary-bg: oklch(0.218 0.008 223.9);
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
--aa-primary-color-rgb: #fa5252 !important;
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 24,24,24 !important;
--aa-background-color-rgb: 24, 24, 24 !important;
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.455 0.188 13.697);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.645 0.246 16.439);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
/* Reset form elements that don't inherit by default (no preflight in Docusaurus).
Must live inside @layer theme so layer(utilities) wins in the cascade. */
@layer theme {
:where(main.marketplace, [data-slot="dialog-portal"]) :where(button, input, select, textarea) {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
margin: 0;
}
:where(main.marketplace, [data-slot="dialog-portal"]) :where(button) {
cursor: pointer;
}
:where([data-slot="dialog-portal"]) {
font-family: var(--ifm-font-family-base);
color: var(--foreground);
}
}
.aa-DetachedOverlay {
@@ -81,7 +217,7 @@
}
.footer__copyright span.copyright_text {
opacity: .6;
opacity: 0.6;
}
.medium-zoom-overlay {
@@ -98,7 +234,7 @@
.announcementBar_node_modules-\@docusaurus-theme-classic-lib-theme-AnnouncementBar-styles-module {
background-color: transparent !important;
background-image: radial-gradient(circle, rgb(253, 126, 20) 0%, rgb(250, 82, 82) 100%) !important;
background-image: radial-gradient(circle, rgb(253, 126, 20) 0%, rgb(250, 82, 82) 100%) !important;
font-weight: 600;
}
@@ -136,7 +272,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
color: var(--ifm-color-primary);
}
:is(p, h3, h4, h5, h6):has(+ :is(ul, ol)){
:is(p, h3, h4, h5, h6):has(+ :is(ul, ol)) {
margin-bottom: 4px !important;
}
@@ -165,7 +301,6 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
padding: 6px 12px;
}
.hero-cards {
transform: rotate(20deg) scale(0.8) skew(21deg);
-webkit-transform: rotate(20deg) scale(0.8) skew(21deg);
@@ -181,7 +316,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
}
.app-card img {
transition: transform 0.1s ease-in-out;
transition: transform 0.1s ease-in-out;
}
.app-card:hover img {
@@ -189,7 +324,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
}
.hover-animation {
animation: hover-animation 5s ease-in-out forwards;
animation: hover-animation 5s ease-in-out forwards;
animation-iteration-count: infinite;
}
@@ -204,3 +339,32 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
transform: translateY(0);
}
}
.submit-step-enter {
animation: submit-step-enter 0.2s ease-in-out forwards;
}
.submit-step-from-right {
--submit-step-x: 80px;
}
.submit-step-from-left {
--submit-step-x: -80px;
}
@media (prefers-reduced-motion: reduce) {
.submit-step-enter {
animation: none;
}
}
@keyframes submit-step-enter {
from {
transform: translateX(var(--submit-step-x, 80px));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
+52
View File
@@ -0,0 +1,52 @@
import PocketBase from "pocketbase";
let client: PocketBase | undefined;
let clientUrl: string | undefined;
export const getPocketBase = (url: string): PocketBase => {
if (!client || clientUrl !== url) {
client = new PocketBase(url);
clientUrl = url;
}
return client;
};
export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) =>
`${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`;
export interface StoreSubmission {
id: string;
collectionId: string;
collectionName: string;
type: "css" | "widget";
title: string;
description: string;
schemaVersion: string;
content: string;
screenshots: string[];
upvotes: number;
downvotes: number;
commentCount: number;
version: number;
changelog: string;
author: string;
authorName: string;
created: string;
}
export interface StoreVote {
id: string;
submission: string;
user: string;
value: 1 | -1;
}
export interface StoreComment {
id: string;
submission: string;
author: string;
content: string;
created: string;
updated: string;
expand?: { author?: { id: string; name?: string; username?: string } };
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { validateSubmissionContent } from "./store-schema";
const validWidget = {
$schema: "homarr-custom-widget-v2",
name: "Example",
url: "https://example.com/api",
authType: "none",
method: "GET",
displayType: "singleValue",
displayConfig: { type: "singleValue", jsonPath: "$.count", label: "Count", unit: "" },
};
describe("validateSubmissionContent", () => {
it("accepts a valid homarr-custom-widget-v2 JSON", () => {
expect(validateSubmissionContent("widget", JSON.stringify(validWidget)).success).toBe(true);
});
it("rejects an old/unknown widget schema version", () => {
const result = validateSubmissionContent(
"widget",
JSON.stringify({ ...validWidget, $schema: "homarr-custom-widget-v1" }),
);
expect(result.success).toBe(false);
});
it("rejects widgets without a schema version", () => {
const widgetWithoutSchema = {
name: validWidget.name,
url: validWidget.url,
authType: validWidget.authType,
method: validWidget.method,
displayType: validWidget.displayType,
displayConfig: validWidget.displayConfig,
};
expect(validateSubmissionContent("widget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
});
it("rejects malformed widget JSON", () => {
expect(validateSubmissionContent("widget", "{ not json").success).toBe(false);
});
it("accepts non-empty CSS and rejects empty CSS", () => {
expect(validateSubmissionContent("css", ".card { color: red; }").success).toBe(true);
expect(validateSubmissionContent("css", " ").success).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { z } from "zod";
export type SubmissionType = "css" | "widget";
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
export const MAX_CSS_LENGTH = 16384;
export type StoreValidationResult = { success: true; data: unknown } | { success: false; error: string };
const authTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
const displayTypes = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
] as const;
const widgetSchema = z
.object({
$schema: z.literal(WIDGET_SCHEMA_VERSION),
name: z.string().min(1).max(128),
url: z.string().min(1),
authType: z.enum(authTypes),
method: z.enum(methods),
displayType: z.enum(displayTypes),
displayConfig: z.object({ type: z.enum(displayTypes) }),
})
.refine((widget) => widget.displayConfig.type === widget.displayType, {
message: "displayConfig.type must match displayType",
});
const validateWidgetContent = (raw: string): StoreValidationResult => {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { success: false, error: "Content is not valid JSON" };
}
const result = widgetSchema.safeParse(parsed);
if (!result.success) {
return { success: false, error: result.error.issues[0]?.message ?? "Invalid custom widget JSON" };
}
return { success: true, data: result.data };
};
const validateCssContent = (raw: string): StoreValidationResult => {
if (raw.trim().length === 0) {
return { success: false, error: "CSS cannot be empty" };
}
if (raw.length > MAX_CSS_LENGTH) {
return { success: false, error: `CSS must be at most ${MAX_CSS_LENGTH} characters` };
}
return { success: true, data: raw };
};
export const submissionValidators = {
widget: validateWidgetContent,
css: validateCssContent,
} satisfies Record<SubmissionType, (raw: string) => StoreValidationResult>;
export const schemaVersionByType = {
widget: WIDGET_SCHEMA_VERSION,
css: CSS_SCHEMA_VERSION,
} satisfies Record<SubmissionType, string>;
export const validateSubmissionContent = (type: SubmissionType, raw: string): StoreValidationResult =>
submissionValidators[type](raw);
+11
View File
@@ -0,0 +1,11 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function errorMessage(caught: unknown, fallback: string) {
return caught instanceof Error ? caught.message : fallback;
}
+22
View File
@@ -0,0 +1,22 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
export default function StorePage() {
const { siteConfig } = useDocusaurusContext();
const storeUrl = (siteConfig.customFields?.storeUrl as string | undefined) ?? "http://localhost:8090";
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<div style={{ minHeight: "50vh" }} />}>
{() => {
const { StoreApp } = require("@site/src/components/store/StoreApp");
return <StoreApp storeUrl={storeUrl} />;
}}
</BrowserOnly>
</main>
</Layout>
);
}
+6 -4
View File
@@ -1,9 +1,6 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"target": "es5",
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
@@ -12,10 +9,15 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*"],
"@site/*": ["./*"]
},
},
"include": [
"**/*.ts",
+12 -18
View File
@@ -5,21 +5,9 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const homarrVersion = (() => {
for (const candidate of ["../../package.json", "./package.json"]) {
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
if (pkg.name === "homarr") return pkg.version as string;
} catch {}
}
return "unknown";
})();
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts",
@@ -27,12 +15,10 @@ const withNextIntl = createNextIntlPlugin({
const nextConfig: NextConfig = {
env: {
HOMARR_VERSION: homarrVersion,
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
},
output: "standalone",
reactStrictMode: true,
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
productionBrowserSourceMaps: true,
// react compiler breaks mantine-react-table, so disabled for now
//reactCompiler: true,
/** We already do typechecking as separate tasks in CI */
@@ -43,13 +29,21 @@ const nextConfig: NextConfig = {
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
serverSourceMaps: true,
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
webpackMemoryOptimizations: true,
},
turbopack: {
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
// (see vercel/next.js#95125). Suppress until upstream fix lands.
ignoreIssue: [
{
path: "**/*",
title: "Encountered unexpected file in NFT list",
},
],
},
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: {
localPatterns: [
+3 -2
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build",
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"format": "oxfmt .",
@@ -59,10 +59,11 @@
"@modelcontextprotocol/sdk": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-persist-client-core": "catalog:",
"@tanstack/query-async-storage-persister": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
"@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:",
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
@@ -1,49 +1,32 @@
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
import { parse, stringify } from "superjson";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import superjson from "superjson";
import { fetchApi } from "@homarr/api/client";
import {
getActiveQueryCacheBoardId,
isPersistableWidgetQueryKey,
queryCacheStoragePrefix,
} from "@homarr/api/query-cache";
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
const queryCacheStorage = {
async getItem(_key) {
return null;
},
async setItem(key, value) {
getItem: (_key: string) => null as string | null,
async setItem(_key: string, value: string) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
} catch {
// Query persistence is best-effort; failed writes should not affect widget rendering.
}
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
console.warn("[query-cache] persist failed", error);
});
},
async removeItem(key) {
async removeItem(_key: string) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
} catch {
// Query persistence is best-effort; failed removals should not affect widget rendering.
}
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
console.warn("[query-cache] remove failed", error);
});
},
} satisfies AsyncStorage<string>;
};
export const createWidgetQueryPersister = () =>
experimental_createQueryPersister<string>({
createAsyncStoragePersister({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
maxAge: Number.POSITIVE_INFINITY,
prefix: queryCacheStoragePrefix,
refetchOnRestore: true,
serialize: (query) => stringify(query),
deserialize: (value) => parse<PersistedQuery>(value),
filters: {
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
},
key: queryCacheStoragePrefix,
throttleTime: 2000,
serialize: (data) => superjson.stringify(data),
deserialize: (data) => superjson.parse(data),
});
@@ -2,9 +2,10 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import { QueryClient, QueryClientProvider } 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";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
createWSClient,
httpBatchStreamLink,
@@ -21,7 +22,13 @@ import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import {
isPersistableWidgetQueryKey,
queryCacheBuster,
queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
} from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
@@ -29,7 +36,6 @@ import { showWarningNotification } from "@homarr/notifications";
import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => {
// window is not defined on server side
if (typeof window === "undefined") {
return "ws";
}
@@ -55,32 +61,36 @@ const wsClient = createWSClient({
});
export function TRPCReactProvider(props: PropsWithChildren) {
const [queryPersister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
persister: queryPersister.persisterFn,
staleTime: queryCacheDefaultStaleTimeMs,
},
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
const [persister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState(() => {
const client = new QueryClient({
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
gcTime: queryCacheDefaultGcTimeMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 3,
},
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
},
}),
);
},
});
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
return client;
});
const [trpcClient] = useState(() => {
return clientApi.createClient({
@@ -98,9 +108,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
true: httpLink({
/**
* We don't want to transform the data here as we want to use form data
*/
transformer: {
serialize(object: unknown) {
return object;
@@ -115,7 +122,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: httpBatchStreamLink({
transformer: superjson,
url: getTrpcUrl(),
maxURLLength: 2083, // Suggested by tRPC: https://trpc.io/docs/client/links/httpBatchLink#setting-a-maximum-url-length
maxURLLength: 2083,
headers: createHeadersCallbackForSource("nextjs-react (json)"),
}),
}),
@@ -126,10 +133,21 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
buster: queryCacheBuster,
maxAge: queryCacheDefaultGcTimeMs,
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
},
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</PersistQueryClientProvider>
</clientApi.Provider>
);
}
@@ -5,10 +5,10 @@ import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import type { DehydratedState } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { parse } from "superjson";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import superjson from "superjson";
import { queryCacheBuster } from "@homarr/api/query-cache";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
@@ -17,7 +17,7 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { getAllQueryCacheAsync } from "@homarr/redis";
import { getQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -118,36 +118,18 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
};
};
interface PersistedEntry {
state: { data: unknown; dataUpdatedAt: number; status: string };
queryKey: unknown[];
queryHash: string;
}
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
const entries = await getAllQueryCacheAsync(userId, boardId);
const queries: DehydratedState["queries"] = [];
const serialized = await getQueryCacheAsync(userId, boardId);
if (!serialized) return null;
for (const serialized of Object.values(entries)) {
try {
const persisted = parse<PersistedEntry>(serialized);
if (persisted.state.status !== "success") continue;
const persisted = superjson.parse<PersistedClient>(serialized);
if (persisted.buster !== queryCacheBuster) return null;
if (!persisted?.clientState?.queries?.length) return null;
queries.push({
state: persisted.state as DehydratedState["queries"][number]["state"],
queryKey: persisted.queryKey,
queryHash: persisted.queryHash,
});
} catch {
// skip malformed entries
}
}
if (queries.length === 0) return null;
return <HydrationBoundary state={{ mutations: [], queries }} />;
} catch {
return <HydrationBoundary state={persisted.clientState} />;
} catch (error) {
logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
return null;
}
}
@@ -9,7 +9,6 @@ import {
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconExternalLink,
IconLayoutBoard,
IconPencil,
IconPencilOff,
@@ -22,11 +21,8 @@ import {
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { openAppsInNewTabs } from "~/components/board/open-apps-in-new-tabs";
import { filterByItemKind } from "~/components/board/sections/category/filter";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
@@ -50,34 +46,9 @@ export const BoardContentHeaderActions = () => {
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
const settings = useSettings();
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind(
board.items.filter((item) => item.layouts.some((layout) => layout.layoutId === currentLayoutId)),
settings,
"app",
).map((item) => {
return item.options.appId;
});
await openAppsInNewTabs(appIds, { t, openConfirmModal });
}, [board, t, openConfirmModal, settings]);
if (!hasChangeAccess || isLoading) {
return (
<>
{!isLoading && (
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
<IconExternalLink stroke={1.5} />
</HeaderButton>
)}
<SelectBoardsMenu />
</>
);
return <SelectBoardsMenu />;
}
return (
@@ -86,12 +57,6 @@ export const BoardContentHeaderActions = () => {
<EditModeMenu demoMode={demoMode ?? false} />
{!isEditMode && (
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
<IconExternalLink stroke={1.5} />
</HeaderButton>
)}
{!demoMode && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
@@ -11,6 +11,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { InstallCssFromStoreButton } from "~/components/store-install/install-from-store-buttons";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
@@ -45,7 +46,8 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="end">
<Group justify="space-between">
<InstallCssFromStoreButton onSelect={(css) => form.setFieldValue("customCss", css)} />
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Popover, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconSparkles } from "@tabler/icons-react";
@@ -296,8 +297,11 @@ interface CopyAiPromptButtonProps {
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const [opened, { open, close }] = useDisclosure(false);
const [mounted, setMounted] = useState(false);
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
useEffect(() => setMounted(true), []);
const handleCopy = async () => {
if (!schema) return;
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
@@ -317,8 +321,8 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={open}
loading={isLoading}
disabled={!schema}
loading={!mounted || isLoading}
disabled={!mounted || isLoading || !schema}
fullWidth
size="sm"
>
@@ -30,9 +30,11 @@ import { useScopedI18n } from "@homarr/translation/client";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetImport,
CustomWidgetMethod,
CustomWidgetSecretKind,
} from "@homarr/validation/custom-widget";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import type { displayConfigSchema } from "@homarr/validation/custom-widget";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
@@ -467,6 +469,90 @@ const serverToFormFieldMap: Record<string, Record<string, string>> = {
customJsx: { template: "template" },
};
const importToFormValues: Record<string, (config: Record<string, unknown>) => Record<string, unknown>> = {
singleValue: (c) => ({
jsonPath: c.jsonPath,
label: c.label ?? "",
unit: c.unit ?? "",
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (c) => ({
mappings:
(c.mappings as Array<Record<string, string>>)?.map((m) => ({
label: m.label,
jsonPath: m.jsonPath,
unit: m.unit ?? "",
})) ?? [],
kvLayout: c.layout ?? "list",
kvColumns: c.columns ?? 2,
}),
table: (c) => ({
tablePath: c.tablePath,
columns: c.columns ?? [],
striped: c.striped ?? true,
compact: c.compact ?? false,
}),
statGrid: (c) => ({
statGridItems: c.items ?? [],
statGridColumns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (c) => ({
progressBars:
(c.bars as Array<Record<string, unknown>>)?.map((b) => ({
label: b.label ?? "",
valuePath: b.valuePath ?? "",
maxPath: b.maxPath ?? "",
unit: b.unit ?? "",
color: b.color ?? "blue",
})) ?? [],
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (c) => ({
statusItems:
(c.items as Array<Record<string, unknown>>)?.map((i) => ({
label: i.label ?? "",
jsonPath: i.jsonPath ?? "",
goodValues: Array.isArray(i.goodValues) ? (i.goodValues as string[]).join(", ") : String(i.goodValues ?? ""),
})) ?? [],
statusLayout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (c) => ({
countGridItems: c.items ?? [],
countGridColumns: c.columns ?? 2,
countValueSize: c.valueSize ?? "md",
}),
raw: (c) => ({ rawJsonPath: c.jsonPath ?? "$", rawMaxHeight: c.maxHeight ?? 300 }),
actionButton: (c) => ({
buttonLabel: c.buttonLabel ?? "",
buttonColor: c.buttonColor ?? "blue",
confirmText: c.confirmText ?? "",
successMessage: c.successMessage ?? "",
}),
customJsx: (c) => ({ template: c.template ?? "" }),
};
function importJsonToFormValues(imported: CustomWidgetImport): Partial<z.infer<typeof formSchema>> {
const displayFields =
importToFormValues[imported.displayType]?.(imported.displayConfig as unknown as Record<string, unknown>) ?? {};
return {
name: imported.name,
description: imported.description ?? "",
iconUrl: imported.iconUrl ?? "",
url: imported.url,
authType: imported.authType,
headerName: imported.headerName ?? "",
method: imported.method,
requestBody: imported.requestBody ?? "",
displayType: imported.displayType,
...displayFields,
secrets: [],
};
}
function extractServerErrors(err: unknown, displayType: string): Record<string, string> {
const errors: Record<string, string> = {};
const trpcErr = err as {
@@ -681,6 +767,32 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
}
}, [getPreviewInput, previewMutation]);
useEffect(() => {
if (mode !== "create") return;
const onPaste = (e: ClipboardEvent) => {
const raw = e.clipboardData?.getData("text/plain");
if (!raw) return;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success) return;
e.preventDefault();
const formValues = importJsonToFormValues(result.data);
form.setValues({ ...defaultCreateValues, ...formValues });
form.clearErrors();
showSuccessNotification({
title: t("action.paste"),
message: t("notification.imported", { name: result.data.name }),
});
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, [form, t, mode]);
useEffect(() => {
if (previewRefreshSignal > 0 && hasTestedRef.current) {
void handlePreviewTest();
@@ -1,11 +1,16 @@
"use client";
import { useEffect } from "react";
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
import { IconApi, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
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 { Link } from "@homarr/ui";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions";
@@ -31,6 +36,36 @@ interface CustomWidgetListProps {
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
const t = useScopedI18n("customWidget");
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("action.paste"), message: "Custom widget imported" });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: (err) => {
showErrorNotification({ title: t("action.paste"), message: err.message || "Failed to import" });
},
});
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const raw = e.clipboardData?.getData("text/plain");
if (!raw) return;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success) return;
e.preventDefault();
importMutation.mutate(result.data);
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, [importMutation.mutate]);
if (definitions.length === 0) {
return (
@@ -44,36 +79,40 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
return (
<Stack gap="md">
<Table striped highlightOnHover withTableBorder>
<Table striped highlightOnHover withTableBorder layout="fixed">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("table.name")}</Table.Th>
<Table.Th>{t("table.url")}</Table.Th>
<Table.Th>{t("table.display")}</Table.Th>
<Table.Th w="30%" visibleFrom="sm">
{t("table.url")}
</Table.Th>
<Table.Th w={120} visibleFrom="xs">
{t("table.display")}
</Table.Th>
<Table.Th w={90} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{definitions.map((def) => (
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
<Table.Td>
<Table.Td style={{ overflow: "hidden" }}>
<Group gap="xs" wrap="nowrap">
{def.iconUrl ? (
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<IconApi size={16} />
<IconApi size={16} style={{ flexShrink: 0 }} />
)}
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate c={def.enabled ? undefined : "dimmed"}>
{def.name}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed" lineClamp={1}>
<Table.Td style={{ overflow: "hidden" }} visibleFrom="sm">
<Text size="sm" c="dimmed" truncate>
{def.url}
</Text>
</Table.Td>
<Table.Td>
<Table.Td visibleFrom="xs">
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
{t(`displayType.${def.displayType}` as never)}
</Badge>
@@ -9,6 +9,7 @@ import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { InstallWidgetFromStoreButton } from "~/components/store-install/install-from-store-buttons";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { CustomWidgetList } from "./_custom-widget-list";
@@ -28,6 +29,7 @@ export default async function CustomWidgetsPage() {
title={t("page.list.title")}
primaryAction={
<Group gap="xs">
<InstallWidgetFromStoreButton />
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
@@ -10,6 +10,7 @@ import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
@@ -79,17 +80,20 @@ export default async function ApiPage() {
const toolGroups = getMcpToolGroups();
return (
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
<>
<DynamicBreadcrumb />
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
</>
);
}
@@ -6,6 +6,7 @@ import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
@@ -31,10 +32,13 @@ export default async function BackupPage() {
const t = await getScopedI18n("management.page.tool.backup");
return (
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
<>
<DynamicBreadcrumb />
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
</>
);
}
Loaded 100 of 423 files, more files were not shown because too many files have changed in this diff. Show more